Merge main and preserve workspace state in unified agent streaming

This commit is contained in:
celanwang 2026-11-05 19:24:41 +08:00
commit 831e3969d0
267 changed files with 43090 additions and 1506 deletions

View file

@ -64,6 +64,7 @@ jobs:
python -m zipfile -l "${REME_WHEEL}" | (! grep 'reme_studio/')
python -m venv "${RUNNER_TEMP}/reme-package-smoke"
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -m pip install "${REME_WHEEL}[as]"
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -m pip check
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-package-smoke/bin/python" -c "import reme"
@ -93,16 +94,6 @@ jobs:
assert runtime.config["jobs"]["auto_bash"]["steps"] == [{"backend": "auto_bash_step"}]
PY
- name: Reject incompatible published ReMe for Home
run: |
HOME_WHEEL="$(pwd)/$(ls dist/home/reme_home-[0-9]*.whl)"
if python -m pip install --dry-run --ignore-installed "${HOME_WHEEL}" "reme-ai[as]==0.4.2.0" > "${RUNNER_TEMP}/home-incompatible.log" 2>&1; then
echo 'Home must not install with the published ReMe 0.4.2.0 wheel.'
exit 1
fi
cat "${RUNNER_TEMP}/home-incompatible.log"
grep -q 'ResolutionImpossible' "${RUNNER_TEMP}/home-incompatible.log"
- name: Verify released core dependencies
if: inputs.upload_artifacts
run: |

6
.gitignore vendored
View file

@ -45,6 +45,9 @@ tmp*/
temp*/
.trash/
# Local tool indexes (rebuildable, machine-local)
.codegraph/
# ReMe runtime data
.reme/
reme_workspace/
@ -59,6 +62,9 @@ docs/_build/
site/
evaluation/
# Home's evaluation package is source code, not benchmark output.
!plugins/home/src/reme_home/evaluation/
!plugins/home/src/reme_home/evaluation/*.py
# The pi-Bench suite ships its own trace-history render config, which must
# stay in git even though it lives under an evaluation/ directory.
!benchmark/pibench/config/bench/evaluation/

View file

@ -142,3 +142,101 @@ Memory Construction average token consumption (default agent, full build over 35
| Agent | input tok/case | output tok/case | total tok/case |
|---|---|---|---|
| default | 31,943,817 | 1,417,061 | 33,360,878 |
## Prepare a Home experiment
`run_home.py` prepares all BEAM **100K** cases, the workspace, and committed candidate
code. It exits after preparation; it does not evaluate cases or start optimization.
Run from the repository root after obtaining the dataset described above:
```bash
reme plugins install plugins/home
python benchmark/beam/run_home.py
# Optional separate workspace (a successful preparation cannot be repeated):
python benchmark/beam/run_home.py --workspace-dir /path/to/new-workspace
```
[config_home.yaml](config_home.yaml) controls the preparation. Its paths are
repository-relative regardless of the current working directory; `--config`
accepts an alternative runner YAML file. The default workspace is
`benchmark/beam/workspaces/home-100K`. The evaluated runtime loads `beam`;
the host application loads `home`. `source_ref: HEAD` exports committed source
only, excluding uncommitted modifications. Preparation failure rolls back its
outputs; after success, another call fails instead of resetting the experiment.
The top-level `sampling_policy` configures Home evaluation defaults: `max_cases`,
`max_queries` (null means unlimited), `temperature`, `ewma_alpha` and
`uniform_exploration`. The runner validates these and passes them to the host
`home.evaluation` Component. It still only prepares; it does not run evaluations.
Each evaluation controls `sampling` and `record_history` separately.
The top-level `validation_cycle_time_budget_minutes` configures the host optimizer's
advisory time between completed formal evaluations. It defaults to 150 minutes;
set a positive number to change it or null to disable timed reminders.
The top-level `max_evaluations` sets the optimizer's formal-evaluation budget
(positive integer, default 10). A slot is consumed at first sandbox startup,
including later failures or cancellations; initial baseline and debug are excluded.
The top-level `memory_limit_mib` sets the host agent sandbox memory limit for
optimization, diagnosis, case studies and evaluation reviews. Set `memory_limit_mib: 0`
in the runner YAML to disable the Linux cgroup limit. Null or omission keeps platform
defaults (4096 MiB on Linux); macOS does not enforce memory limits.
After preparation, run local one-shot optimization with the same configuration:
```bash
python benchmark/beam/run_home.py --optimize
# Use the same --config and --workspace-dir overrides as preparation, if supplied.
python benchmark/beam/run_home.py --optimize --instruction "Improve retrieval quality"
```
This starts a fresh application, applies both budgets and the sampling policy,
runs `home_optimize`, and closes the application. It does not repeat preparation.
Python callers can use `await optimize(config_path, workspace_dir=...)` from the
same module. The default `run()` and CLI behavior remain preparation only.
Python callers can use the same lifecycle:
```python
from benchmark.beam.run_home import run
result = await run(workspace_dir="/path/to/new-workspace")
```
## Evaluate a candidate on an independent test set
The default evaluates every 100K case, matching the previous standalone evaluation. This is an independent
workspace, not a disjoint held-out split. Select another `variant`, or replace `all_cases: true` with
`case_ids: [...]`, in [config_home_eval.yaml](config_home_eval.yaml) for a different test set.
The entry point imports a full candidate SHA or unambiguous hexadecimal prefix from an existing Home
optimization workspace and copies its prepared fixed runtime. It does not require the original ReMe source
repository or change the optimizer's checkout. Paths in the configuration are repository-relative.
Prepare first, then use the service from two terminals (replace `CANDIDATE_SHA`):
```bash
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
# Terminal 1:
reme start workspace_dir=benchmark/beam/workspaces/home-test-100K 'plugins=["home","beam-judge"]'
# Terminal 2:
reme home_evaluate workspace_dir=benchmark/beam/workspaces/home-test-100K record_history=false
```
### Local one-shot test evaluation
```bash
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA
# Optional overrides: --config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
```
Python callers can use `await benchmark.beam.run_home_eval.run(commit="CANDIDATE_SHA")`.
The runner rebuilds construction and scores every selected query with sampling and history recording disabled.
It uses the existing Docker evaluation runtime and host judge configuration; model credentials are required
for evaluation. Results stay in the test workspace's `home/evaluations/`, with original candidate provenance
in each manifest's `test_evaluation`. The exported snapshot has its own local `evaluation_commit_sha`.
Rerunning the same request reopens unchanged prepared inputs and creates another evaluation result. Use a
new test workspace for another candidate or selection. Source and test workspaces must not overlap.

View file

@ -130,3 +130,84 @@ Memory Construction 平均 token 消耗(default agent,35 cases 全量构建
| Agent | input tok/case | output tok/case | total tok/case |
|---|---|---|---|
| default | 31,943,817 | 1,417,061 | 33,360,878 |
## 准备 Home 实验
安装 Home 插件后,在仓库根目录执行:
```bash
reme plugins install plugins/home
python benchmark/beam/run_home.py
```
该入口只创建工作区、准备数据和代码,不运行评测或优化。
参数见 [config_home.yaml](config_home.yaml),路径相对仓库根目录;
可通过 `--config` 更换配置,通过 `--workspace-dir` 指定新工作区。
默认工作区为 `benchmark/beam/workspaces/home-100K`。
代码从 `source_ref: HEAD` 对应的 Git 提交导出,不包含未提交修改。
准备失败会回滚;成功后禁止再次准备同一工作区。
Python 中也可调用 `await benchmark.beam.run_home.run(workspace_dir=...)`。
顶层 `sampling_policy` 配置采样默认值:`max_cases`、`max_queries`(null 表示不限)、
`temperature`、`ewma_alpha` 和 `uniform_exploration`。入口校验后将其传给宿主
`home.evaluation` Component,仍只准备、不执行评测;每次评测分别控制 `sampling`
和 `record_history`。
顶层 `validation_cycle_time_budget_minutes` 配置宿主 optimizer 两次已完成正式评测之间的
建议用时,默认 150 分钟;可改为其他正数,或设为 null 关闭时间提醒。
顶层 `max_evaluations` 配置 optimizer 的正式评测次数预算,必须为正整数,默认 10。
首次成功启动 sandbox 时扣一次,之后失败或取消也不退还;初始 baseline 和 debug 不计入。
顶层 `memory_limit_mib` 配置优化、诊断、case study 和评估审查的宿主 agent 沙箱内存限制。
在 runner YAML 中设置 `memory_limit_mib: 0` 可关闭 Linux cgroup 内存限制;
省略或设为 null 时保留平台默认值(Linux 为 4096 MiB),macOS 不执行内存限制。
准备完成后,用同一份配置进行本地一次性优化:
```bash
python benchmark/beam/run_home.py --optimize
```
若准备时指定了 `--config` 或 `--workspace-dir`,优化时传入相同参数。
可用 `--instruction "优化检索质量"` 指定目标。该入口将配置中的两个预算和采样策略
应用到实际 `home_optimize` 调用,不重复准备;Python 可调用同模块的 `await optimize(...)`。
不加 `--optimize` 时仍只准备。
默认选择 BEAM **100K 全部 case**。
## 独立测试集评测
默认在独立工作区评测 **100K 全部 case**,与旧独立评测入口一致,不代表与训练集互斥的留出集。
可在 [config_home_eval.yaml](config_home_eval.yaml) 更换 `variant`,或把 `all_cases: true`
替换为 `case_ids: [...]`,指定测试数据。
入口从已有 Home 优化工作区导出指定候选提交,接受完整 SHA 或无歧义的十六进制短 SHA,
并复制该实验已准备的固定 runtime。无需原始 ReMe 源码仓库,也不会切换或修改优化工作区。
配置内路径均相对仓库根目录。
先准备测试工作区,再分别在两个终端启动服务和调用评测(替换 `CANDIDATE_SHA`):
```bash
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
# 终端 1:
reme start workspace_dir=benchmark/beam/workspaces/home-test-100K 'plugins=["home","beam-judge"]'
# 终端 2:
reme home_evaluate workspace_dir=benchmark/beam/workspaces/home-test-100K record_history=false
```
### 本地一次性测试评测
```bash
python benchmark/beam/run_home_eval.py --commit CANDIDATE_SHA
# 可选:--config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
```
Python 可调用 `await benchmark.beam.run_home_eval.run(commit="CANDIDATE_SHA")`。
入口完整重建记忆并对所选 case 的全部 query 评分,关闭采样与优化历史记录,使用现有 Docker
评测环境和宿主 judge 配置;实际评测需要模型凭证。结果保存在测试工作区的 `home/evaluations/`,
manifest 的 `test_evaluation` 记录来源候选,导出后的本地提交另记为 `evaluation_commit_sha`。
相同请求可复用未改动的准备结果并新增一次评测;更换候选或数据选择时需使用新测试工作区。
测试工作区与来源工作区必须互不包含。

View file

@ -0,0 +1,31 @@
# Preparation by default; --optimize applies these settings to the prepared workspace.
# Paths are relative to the ReMe repository root, not the cwd.
workspace_dir: benchmark/beam/workspaces/home-100K
# Formal optimizer evaluations; charged once the first sandbox starts, excluding init/debug.
max_evaluations: 10
# Host agent sandbox memory limit in MiB; null keeps platform defaults, 0 disables the Linux cgroup limit.
memory_limit_mib: 0
# Target minutes between completed formal optimizer evaluations; null disables timed reminders.
validation_cycle_time_budget_minutes: 150
# Policy defaults for Home evaluation; each call still controls sampling=true/false.
sampling_policy:
max_cases: 5
max_queries: null
temperature: 1.0
ewma_alpha: 0.3
uniform_exploration: 0.2
preparation:
benchmark:
name: beam
source: benchmark/beam/dataset/BEAM
variant: 100K
all_cases: true
runtime:
source_repo: .
source_ref: HEAD # Committed source only; resolved and saved as a full SHA.
config: benchmark.yaml
plugins: [beam]
debug:
enabled: true
sessions_per_case: 3
messages_per_session: 6

View file

@ -0,0 +1,13 @@
# Independent full-dataset evaluation; paths are relative to the ReMe repository.
source_workspace: benchmark/beam/workspaces/home-100K
test_workspace: benchmark/beam/workspaces/home-test-100K
code_commit: "" # Required: set a candidate SHA/prefix, or pass --commit.
benchmark:
name: beam
source: benchmark/beam/dataset/BEAM
variant: 100K
all_cases: true # Full 100K evaluation; not a disjoint held-out split.
evaluation:
concurrency: 4
timeout_seconds: 7200
record_history: false

173
benchmark/beam/run_home.py Normal file
View file

@ -0,0 +1,173 @@
"""Prepare the BEAM Home workspace, or optimize it with --optimize after preparation."""
import argparse
import asyncio
import json
import sys
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
import yaml
# Support direct execution from any working directory as well as module imports.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# pylint: disable=wrong-import-position
from reme_home.preparation.spec import PreparationSpec # noqa: E402
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
from reme_home.sampling import SamplingPolicy # noqa: E402
from reme.application import Application # noqa: E402
from reme.utils.env_utils import load_env # noqa: E402
# pylint: enable=wrong-import-position
DEFAULT_CONFIG = Path(__file__).with_name("config_home.yaml")
class RunHomeConfig(BaseModel):
"""Runner settings kept separate from Home's public preparation contract."""
model_config = ConfigDict(extra="forbid", strict=True)
workspace_dir: str = Field(min_length=1)
preparation: dict
sampling_policy: SamplingPolicy = Field(default_factory=SamplingPolicy)
validation_cycle_time_budget_minutes: float | None = Field(default=150, gt=0, allow_inf_nan=False)
max_evaluations: int = Field(default=10, ge=1)
memory_limit_mib: int | None = Field(default=None, ge=0)
def project_path(value: str, project_root: Path) -> Path:
"""Use repository-relative paths consistently regardless of the caller's cwd."""
if value.startswith("~"):
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
return (project_root / value).resolve()
def _load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
"""Resolve source paths and validate explicit Home preparation parameters."""
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
config = RunHomeConfig.model_validate(raw)
config.preparation.setdefault("candidate", {"paths": ["plugins/beam/src"]})
spec = PreparationSpec.model_validate(config.preparation)
spec.benchmark.source = str(project_path(spec.benchmark.source, project_root))
if spec.benchmark.evidence_source is not None:
spec.benchmark.evidence_source = str(project_path(spec.benchmark.evidence_source, project_root))
spec.runtime.source_repo = str(project_path(spec.runtime.source_repo, project_root))
workspace = project_path(workspace_dir if workspace_dir is not None else config.workspace_dir, project_root)
return workspace, spec.model_dump(mode="json"), config
def load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
"""Return resolved preparation inputs, preserving the preparation-only caller contract."""
workspace, spec, _ = _load_config(config_path, workspace_dir=workspace_dir, project_root=project_root)
return workspace, spec
async def _run(config_path, *, workspace_dir=None, optimize_workspace=False, instruction=None):
"""Apply the same runner configuration to each preparation or optimization lifecycle."""
workspace, spec, config = _load_config(config_path, workspace_dir=workspace_dir)
load_env(PROJECT_ROOT / ".env", override=False)
memory_options = {}
jobs = {}
if config.memory_limit_mib is not None:
memory_options = {
"candidate_gate_memory_limit_mib": config.memory_limit_mib,
"construction_equivalence_memory_limit_mib": config.memory_limit_mib,
}
for name, backend, wrapper in (
("home_optimize", "home_optimize_step", "home_optimizer"),
("home_diagnose", "home_diagnose_step", "home_diagnostic"),
("home_case_study", "home_case_study_step", "home_case_study"),
):
jobs[name] = {
"steps": [
{
"backend": backend,
"agent_wrapper": wrapper,
"memory_limit_mib": config.memory_limit_mib,
**({"concurrency": 4, "output_token_limit": 2000} if name == "home_case_study" else {}),
},
],
}
app = Application(
workspace_dir=str(workspace),
plugins=["home", JUDGE_PLUGINS[spec["benchmark"]["name"]]],
components={
"home.evaluation": {
"default": {"sampling_policy": config.sampling_policy.model_dump(mode="json"), **memory_options},
},
"agent_wrapper": {
"home_optimizer": {
"validation_cycle_time_budget_minutes": config.validation_cycle_time_budget_minutes,
"max_evaluations": config.max_evaluations,
},
},
},
jobs=jobs,
service={"backend": "cli"},
enable_logo=False,
log_to_file=False,
)
try:
await app.start()
if optimize_workspace:
arguments = {} if instruction is None else {"instruction": instruction}
response = await app.run_job("home_optimize", **arguments)
else:
response = await app.run_job("home_prepare", spec=spec)
if not response.success:
raise RuntimeError(response.answer)
return {"workspace_dir": str(workspace), **response.answer}
finally:
await app.close()
async def prepare(config_path: str | Path, *, workspace_dir: str | None = None) -> dict:
"""Prepare once without running evaluations or optimization."""
return await _run(config_path, workspace_dir=workspace_dir)
async def optimize(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir=None, instruction=None) -> dict:
"""Optimize an already prepared workspace using this config's actual model budgets."""
return await _run(config_path, workspace_dir=workspace_dir, optimize_workspace=True, instruction=instruction)
async def run(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir: str | None = None) -> dict:
"""Prepare through the same Home Job as the CLI, then close the application."""
return await prepare(config_path, workspace_dir=workspace_dir)
def main(default_config: Path = DEFAULT_CONFIG) -> None:
"""Run preparation or explicit local one-shot optimization with the benchmark config."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=default_config, help="Home runner YAML configuration")
parser.add_argument("--workspace-dir", help="Override workspace directory (absolute or project-relative)")
parser.add_argument("--optimize", action="store_true", help="Optimize an already prepared workspace")
parser.add_argument("--instruction", help="Optimization focus; requires --optimize")
args = parser.parse_args()
if args.instruction is not None and not args.optimize:
parser.error("--instruction requires --optimize")
if args.optimize:
result = asyncio.run(optimize(args.config, workspace_dir=args.workspace_dir, instruction=args.instruction))
print(json.dumps(result, ensure_ascii=False, indent=2))
return
result = asyncio.run(prepare(args.config, workspace_dir=args.workspace_dir))
print(
json.dumps(
{
"workspace_dir": result["workspace_dir"],
"case_count": result["dataset"]["case_count"],
"query_count": result["dataset"]["query_count"],
"source_commit": result["code"]["source_commit"],
"initial_commit": result["code"]["initial_commit"],
},
indent=2,
),
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,226 @@
"""Evaluate a BEAM Home candidate in an independent test workspace."""
import argparse
import asyncio
import json
import sys
from pathlib import Path
from pydantic import Field, model_validator
import yaml
# Support direct execution from any working directory as well as module imports.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# pylint: disable=wrong-import-position
from reme_home.code.repository import resolve_revision, validate_repository # noqa: E402
from reme_home.evaluation.artifacts import contained, read_json # noqa: E402
from reme_home.evaluation.component import blocking # noqa: E402
from reme_home.evaluation.inputs import EvaluationSpec, load_inputs # noqa: E402
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
from reme_home.preparation.code import tree_fingerprint # noqa: E402
from reme_home.preparation.data.models import fingerprint # noqa: E402
from reme_home.preparation.evaluation_source import EvaluationSource # noqa: E402
from reme_home.preparation.spec import BenchmarkSpec, DebugSpec, SpecModel # noqa: E402
from reme.application import Application # noqa: E402
from reme.utils.env_utils import load_env # noqa: E402
# pylint: enable=wrong-import-position
DEFAULT_CONFIG = Path(__file__).with_name("config_home_eval.yaml")
class TestRunConfig(SpecModel):
"""Explicit dataset selection and execution options for one pinned candidate."""
source_workspace: str = Field(min_length=1)
test_workspace: str = Field(min_length=1)
code_commit: str = Field(min_length=4)
benchmark: BenchmarkSpec
exclude_training_cases: bool = False
evaluation: EvaluationSpec = Field(default_factory=lambda: EvaluationSpec(record_history=False))
@model_validator(mode="after")
def validate_test_mode(self):
"""Test runs score every prepared query and never consume optimizer budgets."""
options = self.evaluation
if any(
(
options.debug,
options.sampling,
options.record_history,
options.max_evaluations is not None,
options.sampling_policy is not None,
options.case_ids is not None,
options.query_ids is not None,
options.reuse_construction_from is not None,
),
):
raise ValueError(
"Test evaluation requires full scoring with record_history=false; select cases in benchmark",
)
if self.exclude_training_cases and (self.benchmark.name != "longmemeval" or not self.benchmark.all_cases):
raise ValueError("exclude_training_cases requires LongMemEval with all_cases=true")
return self
def project_path(value: str, project_root: Path) -> Path:
"""Resolve runner paths independently of the launching working directory."""
if value.startswith("~"):
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
return (project_root / value).resolve()
def load_request(config_path, *, commit=None, source_workspace=None, test_workspace=None, project_root=PROJECT_ROOT):
"""Validate all source inputs before starting an application or writing test data."""
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
for key, value in (
("code_commit", commit),
("source_workspace", source_workspace),
("test_workspace", test_workspace),
):
if value is not None:
raw[key] = value
# Execution settings can be partial while retaining the test-only history default.
raw["evaluation"] = {"record_history": False, **raw.get("evaluation", {})}
config = TestRunConfig.model_validate(raw)
source_path = project_path(config.source_workspace, project_root)
target = project_path(config.test_workspace, project_root)
if target.is_relative_to(source_path) or source_path.is_relative_to(target):
raise ValueError("Test and source workspaces must be separate, non-nested directories")
if target.exists() and (
not target.is_dir() or (any(target.iterdir()) and not (target / "home/metadata/workspace.json").is_file())
):
raise ValueError("Use an empty test workspace or a compatible prepared Home test workspace")
source = EvaluationSource.load(source_path, config.code_commit)
benchmark = config.benchmark.model_copy(deep=True)
if benchmark.name != source.spec.benchmark.name:
raise ValueError("Test benchmark must match the source experiment")
benchmark.source = str(project_path(benchmark.source, project_root).resolve(strict=True))
if benchmark.evidence_source is not None:
benchmark.evidence_source = str(project_path(benchmark.evidence_source, project_root).resolve(strict=True))
if config.exclude_training_cases:
items = json.loads(Path(benchmark.source).read_text(encoding="utf-8"))
ids = [str(item["question_id"]) for item in items]
if len(ids) != len(set(ids)):
raise ValueError("Duplicate LongMemEval question IDs")
training = set(source.record["dataset"]["case_ids"])
if training - set(ids):
raise ValueError("Test dataset is missing training IDs; provide the original full dataset")
benchmark.case_ids = [case_id for case_id in ids if case_id not in training]
benchmark.all_cases = False
benchmark = BenchmarkSpec.model_validate(benchmark.model_dump())
spec = source.spec.model_copy(deep=True)
spec.benchmark = benchmark
spec.debug = DebugSpec(enabled=False)
return config, target, source, spec
def existing_preparation(home, source, spec):
"""Reopen only the same request with unchanged code, runtime and normalized data."""
marker = contained(home, "metadata/preparation.json")
if not marker.exists():
return None
record = read_json(marker)
expected = {**source.provenance, "request_fingerprint": fingerprint(spec.model_dump(mode="json"))}
if record.get("test_evaluation") != expected:
raise ValueError("Test workspace belongs to a different candidate or dataset; use a new workspace")
domain = yaml.safe_load(contained(home, "domain_spec.yaml").read_text(encoding="utf-8"))
if fingerprint(domain) != record["config_fingerprint"]:
raise ValueError("Test preparation configuration has changed")
repository, _ = validate_repository(home, record)
if (
resolve_revision(repository, "HEAD") != record["code"]["initial_commit"]
or tree_fingerprint(repository) != record["code"]["candidate_fingerprint"]
or tree_fingerprint(contained(home, "code/runtime", directory=True)) != source.provenance["runtime_fingerprint"]
):
raise ValueError("Prepared test code or runtime has changed")
load_inputs(home, EvaluationSpec(record_history=False))
return record
async def run_evaluation(
config_path,
*,
commit=None,
source_workspace=None,
test_workspace=None,
prepare_only=False,
expected_benchmark=None,
):
"""Prepare transactionally, then score through the normal application lifecycle."""
config, target, source, spec = await blocking(
load_request,
config_path,
commit=commit,
source_workspace=source_workspace,
test_workspace=test_workspace,
)
if expected_benchmark is not None and config.benchmark.name != expected_benchmark:
raise ValueError(f"This entry point requires benchmark.name={expected_benchmark}")
load_env(PROJECT_ROOT / ".env", override=False)
app = Application(
workspace_dir=str(target),
plugins=["home", JUDGE_PLUGINS[config.benchmark.name]],
service={"backend": "cli"},
enable_logo=False,
log_to_file=False,
)
try:
await app.start()
home = target / "home"
prepared = await blocking(existing_preparation, home, source, spec)
if prepared is None:
preparation = app.context.components["home.preparation"]["default"]
prepared = await preparation.prepare(spec=spec.model_dump(mode="json"), code_source=source)
result = {
"test_workspace": str(target),
**source.provenance,
"evaluation_commit_sha": prepared["code"]["initial_commit"],
"case_count": prepared["dataset"]["case_count"],
"query_count": prepared["dataset"]["query_count"],
}
if not prepare_only:
response = await app.run_job(
"home_evaluate",
**config.evaluation.model_dump(mode="json", exclude_unset=True),
)
if not response.success:
raise RuntimeError(response.answer)
result["evaluation"] = response.answer
return result
finally:
await app.close()
async def run(config_path=DEFAULT_CONFIG, **kwargs):
"""Score the selected commit in its own Home workspace."""
return await run_evaluation(config_path, expected_benchmark="beam", **kwargs)
def main():
"""Explicit local one-shot entry point, with preparation-only service setup."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--commit", help="Candidate commit SHA or unambiguous hexadecimal prefix")
parser.add_argument("--source-workspace", help="Prepared optimization workspace")
parser.add_argument("--test-workspace", help="Independent test output workspace")
parser.add_argument("--prepare-only", action="store_true", help="Prepare for a later home_evaluate service call")
args = parser.parse_args()
result = asyncio.run(
run(
args.config,
commit=args.commit,
source_workspace=args.source_workspace,
test_workspace=args.test_workspace,
prepare_only=args.prepare_only,
),
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View file

@ -112,3 +112,111 @@ agentscope==2.0.4.post1, conda reme env, 32 workers, eval-only (reusing prebuilt
| single-session-user | 0.986 | 27,433 | 359 | 27,792 | 2.60 |
| temporal-reasoning | 0.902 | 62,674 | 985 | 63,659 | 4.97 |
| **OVERALL** | **0.894** | **43,448** | **876** | **44,324** | **3.69** |
## Prepare a Home experiment
`run_home.py` prepares the fixed **group_1–group_5** training split (250 cases), the workspace, and committed candidate
code. It exits after preparation; it does not evaluate cases or start optimization.
Run from the repository root after obtaining the dataset described above:
```bash
reme plugins install plugins/home
python benchmark/longmemeval/run_home.py
# Optional separate workspace (a successful preparation cannot be repeated):
python benchmark/longmemeval/run_home.py --workspace-dir /path/to/new-workspace
```
[config_home.yaml](config_home.yaml) controls the preparation. Its paths are
repository-relative regardless of the current working directory; `--config`
accepts an alternative runner YAML file. The default workspace is
`benchmark/longmemeval/workspaces/home-groups-1-5`. The evaluated runtime loads `lme`;
the host application loads `home`. `source_ref: HEAD` exports committed source
only, excluding uncommitted modifications. Preparation failure rolls back its
outputs; after success, another call fails instead of resetting the experiment.
The top-level `sampling_policy` configures Home evaluation defaults: `max_cases`,
`max_queries` (null means unlimited), `temperature`, `ewma_alpha` and
`uniform_exploration`. The runner validates these and passes them to the host
`home.evaluation` Component. It still only prepares; it does not run evaluations.
Each evaluation controls `sampling` and `record_history` separately.
The top-level `validation_cycle_time_budget_minutes` configures the host optimizer's
advisory time between completed formal evaluations. It defaults to 150 minutes;
set a positive number to change it or null to disable timed reminders.
The top-level `max_evaluations` sets the optimizer's formal-evaluation budget
(positive integer, default 10). A slot is consumed at first sandbox startup,
including later failures or cancellations; initial baseline and debug are excluded.
The top-level `memory_limit_mib` sets the host agent sandbox memory limit for
optimization, diagnosis, case studies and evaluation reviews. Set `memory_limit_mib: 0`
in the runner YAML to disable the Linux cgroup limit. Null or omission keeps platform
defaults (4096 MiB on Linux); macOS does not enforce memory limits.
After preparation, run local one-shot optimization with the same configuration:
```bash
python benchmark/longmemeval/run_home.py --optimize
# Use the same --config and --workspace-dir overrides as preparation, if supplied.
python benchmark/longmemeval/run_home.py --optimize --instruction "Improve retrieval quality"
```
This starts a fresh application, applies both budgets and the sampling policy,
runs `home_optimize`, and closes the application. It does not repeat preparation.
Python callers can use `await optimize(config_path, workspace_dir=...)` from the
same module. The default `run()` and CLI behavior remain preparation only.
Python callers can use the same lifecycle:
```python
from benchmark.longmemeval.run_home import run
result = await run(workspace_dir="/path/to/new-workspace")
```
The 250 `benchmark.case_ids` in [config_home.yaml](config_home.yaml) are copied
in order from groups 1–5 of `dataset/groups.json` at `dev_meta` commit
`b53048e44833937df80a52fccceea78d054790e8`. The original splitter used seed 42
and type-stratified allocation into ten 50-case groups; groups 6–10 remain held
out. The runner reads the IDs directly from YAML without loading group files.
[home_evidence_sessions.jsonl](home_evidence_sessions.jsonl) is a byte-for-byte
copy of that revision's `dataset/longmemeval_evidence_sessions.jsonl`;
reviewed evidence-session annotations are preserved during normalization.
## Evaluate a candidate on an independent test set
The default excludes the source experiment's actual training IDs from the full dataset. With the default
250-case training workspace this selects held-out groups 6–10. For an explicit selection, set
`exclude_training_cases: false` and replace `all_cases: true` with `case_ids: [...]` in
[config_home_eval.yaml](config_home_eval.yaml). An empty held-out selection fails before preparation.
The entry point imports a full candidate SHA or unambiguous hexadecimal prefix from an existing Home
optimization workspace and copies its prepared fixed runtime. It does not require the original ReMe source
repository or change the optimizer's checkout. Paths in the configuration are repository-relative.
Prepare first, then use the service from two terminals (replace `CANDIDATE_SHA`):
```bash
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
# Terminal 1:
reme start workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out 'plugins=["home","lme-judge"]'
# Terminal 2:
reme home_evaluate workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out record_history=false
```
### Local one-shot test evaluation
```bash
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA
# Optional overrides: --config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
```
Python callers can use `await benchmark.longmemeval.run_home_eval.run(commit="CANDIDATE_SHA")`.
The runner rebuilds construction and scores every selected query with sampling and history recording disabled.
It uses the existing Docker evaluation runtime and host judge configuration; model credentials are required
for evaluation. Results stay in the test workspace's `home/evaluations/`, with original candidate provenance
in each manifest's `test_evaluation`. The exported snapshot has its own local `evaluation_commit_sha`.
Rerunning the same request reopens unchanged prepared inputs and creates another evaluation result. Use a
new test workspace for another candidate or selection. Source and test workspaces must not overlap.

View file

@ -101,3 +101,89 @@ agentscope==2.0.4.post1, conda reme env, 32 workers, eval-only(复用预构建
| single-session-user | 0.986 | 27,433 | 359 | 27,792 | 2.60 |
| temporal-reasoning | 0.902 | 62,674 | 985 | 63,659 | 4.97 |
| **OVERALL** | **0.894** | **43,448** | **876** | **44,324** | **3.69** |
## 准备 Home 实验
安装 Home 插件后,在仓库根目录执行:
```bash
reme plugins install plugins/home
python benchmark/longmemeval/run_home.py
```
该入口只创建工作区、准备数据和代码,不运行评测或优化。
参数见 [config_home.yaml](config_home.yaml),路径相对仓库根目录;
可通过 `--config` 更换配置,通过 `--workspace-dir` 指定新工作区。
默认工作区为 `benchmark/longmemeval/workspaces/home-groups-1-5`。
代码从 `source_ref: HEAD` 对应的 Git 提交导出,不包含未提交修改。
准备失败会回滚;成功后禁止再次准备同一工作区。
Python 中也可调用 `await benchmark.longmemeval.run_home.run(workspace_dir=...)`。
顶层 `sampling_policy` 配置采样默认值:`max_cases`、`max_queries`(null 表示不限)、
`temperature`、`ewma_alpha` 和 `uniform_exploration`。入口校验后将其传给宿主
`home.evaluation` Component,仍只准备、不执行评测;每次评测分别控制 `sampling`
和 `record_history`。
顶层 `validation_cycle_time_budget_minutes` 配置宿主 optimizer 两次已完成正式评测之间的
建议用时,默认 150 分钟;可改为其他正数,或设为 null 关闭时间提醒。
顶层 `max_evaluations` 配置 optimizer 的正式评测次数预算,必须为正整数,默认 10。
首次成功启动 sandbox 时扣一次,之后失败或取消也不退还;初始 baseline 和 debug 不计入。
顶层 `memory_limit_mib` 配置优化、诊断、case study 和评估审查的宿主 agent 沙箱内存限制。
在 runner YAML 中设置 `memory_limit_mib: 0` 可关闭 Linux cgroup 内存限制;
省略或设为 null 时保留平台默认值(Linux 为 4096 MiB),macOS 不执行内存限制。
准备完成后,用同一份配置进行本地一次性优化:
```bash
python benchmark/longmemeval/run_home.py --optimize
```
若准备时指定了 `--config` 或 `--workspace-dir`,优化时传入相同参数。
可用 `--instruction "优化检索质量"` 指定目标。该入口将配置中的两个预算和采样策略
应用到实际 `home_optimize` 调用,不重复准备;Python 可调用同模块的 `await optimize(...)`。
不加 `--optimize` 时仍只准备。
默认使用 `dev_meta` 已保存的 **group_1–group_5,共 250 个 case**,
ID 已按原顺序直接写入 [config_home.yaml](config_home.yaml) 的 `benchmark.case_ids`,
运行时无需读取分组文件,group_6–group_10 保持为留出集。
独立审阅的证据标注保存在
[home_evidence_sessions.jsonl](home_evidence_sessions.jsonl),来源见英文说明。
## 独立测试集评测
默认从完整数据中排除来源工作区实际使用的训练 ID;使用默认 250 个训练 case 时,测试集为
**group_6–group_10,共 250 个 case**。若要显式选取测试 case,可在
[config_home_eval.yaml](config_home_eval.yaml) 设置 `exclude_training_cases: false`,
并把 `all_cases: true` 替换为 `case_ids: [...]`。留出集为空时会直接报错。
入口从已有 Home 优化工作区导出指定候选提交,接受完整 SHA 或无歧义的十六进制短 SHA,
并复制该实验已准备的固定 runtime。无需原始 ReMe 源码仓库,也不会切换或修改优化工作区。
配置内路径均相对仓库根目录。
先准备测试工作区,再分别在两个终端启动服务和调用评测(替换 `CANDIDATE_SHA`):
```bash
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA --prepare-only
# 终端 1:
reme start workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out 'plugins=["home","lme-judge"]'
# 终端 2:
reme home_evaluate workspace_dir=benchmark/longmemeval/workspaces/home-test-held-out record_history=false
```
### 本地一次性测试评测
```bash
python benchmark/longmemeval/run_home_eval.py --commit CANDIDATE_SHA
# 可选:--config /path/to/test.yaml --source-workspace /path/to/search --test-workspace /path/to/test
```
Python 可调用 `await benchmark.longmemeval.run_home_eval.run(commit="CANDIDATE_SHA")`。
入口完整重建记忆并对所选 case 的全部 query 评分,关闭采样与优化历史记录,使用现有 Docker
评测环境和宿主 judge 配置;实际评测需要模型凭证。结果保存在测试工作区的 `home/evaluations/`,
manifest 的 `test_evaluation` 记录来源候选,导出后的本地提交另记为 `evaluation_commit_sha`。
相同请求可复用未改动的准备结果并新增一次评测;更换候选或数据选择时需使用新测试工作区。
测试工作区与来源工作区必须互不包含。

View file

@ -0,0 +1,288 @@
# Preparation by default; --optimize applies these settings to the prepared workspace.
# Paths are relative to the ReMe repository root, not the cwd.
# Preserve dev_meta's fixed training split; groups 6-10 remain held out.
workspace_dir: benchmark/longmemeval/workspaces/home-groups-1-5
# Formal optimizer evaluations; charged once the first sandbox starts, excluding init/debug.
max_evaluations: 10
# Host agent sandbox memory limit in MiB; null keeps platform defaults, 0 disables the Linux cgroup limit.
memory_limit_mib: 0
# Policy defaults for Home evaluation; each call still controls sampling=true/false.
sampling_policy:
max_cases: 5
max_queries: null
temperature: 1.0
ewma_alpha: 0.3
uniform_exploration: 0.2
preparation:
benchmark:
name: longmemeval
source: benchmark/longmemeval/dataset/longmemeval_s_reme_cleaned.json
evidence_source: benchmark/longmemeval/home_evidence_sessions.jsonl
# dev_meta b53048e44833937df80a52fccceea78d054790e8: dataset/groups.json, groups 1-5.
case_ids:
# group_1
- "gpt4_1e4a8aec"
- "e3038f8c"
- "gpt4_70e84552_abs"
- "edced276_abs"
- "c6853660"
- "gpt4_372c3eed"
- "gpt4_ec93e27f"
- "c8c3f81d"
- "dccbc061"
- "a06e4cfe"
- "a82c026e"
- "fca762bc"
- "603deb26"
- "bb7c3b45"
- "4baee567"
- "af082822"
- "1a1907b4"
- "gpt4_468eb064"
- "ef9cf60a"
- "gpt4_a2d1d1f6"
- "8fb83627"
- "1c0ddc50"
- "2ebe6c92"
- "e25c3b8d"
- "6aeb4375"
- "bbf86515"
- "8e91e7d9"
- "f0e564bc"
- "eaca4986"
- "gpt4_4fc4f797"
- "gpt4_5501fe77"
- "7a8d0b71"
- "982b5123"
- "f4f1d8a4_abs"
- "e831120c"
- "0977f2af"
- "51c32626"
- "gpt4_ab202e7f"
- "eace081b"
- "d01c6aa8"
- "8aef76bc"
- "d682f1a2"
- "gpt4_468eb063"
- "c5e8278d"
- "ccb36322"
- "affe2881"
- "75f70248"
- "6e984302"
- "ed4ddc30"
- "a11281a2"
# group_2
- "cc6d1ec1"
- "6613b389"
- "c4f10528"
- "1faac195"
- "3d86fd0a"
- "9d25d4e0"
- "b759caee"
- "gpt4_b4a80587"
- "b3c15d39"
- "370a8ff4"
- "bf659f65"
- "5a7937c8"
- "57f827a0"
- "545bd2b5"
- "a96c20ee"
- "caf03d32"
- "dad224aa"
- "50635ada"
- "21436231"
- "031748ae"
- "41275add"
- "51a45a95"
- "gpt4_4929293a"
- "3e321797"
- "0e5e2d1a"
- "b01defab"
- "gpt4_4ef30696"
- "91b15a6e"
- "d6233ab6"
- "c18a7dc8"
- "f0853d11"
- "031748ae_abs"
- "60d45044"
- "bcbe585f"
- "41698283"
- "0ddfec37"
- "46a3abf7"
- "dd2973ad"
- "129d1232"
- "60159905"
- "2b8f3739"
- "c8090214_abs"
- "8464fc84"
- "gpt4_93f6379c"
- "gpt4_2c50253f"
- "gpt4_18c2b244"
- "bc8a6e93_abs"
- "09ba9854"
- "2133c1b5_abs"
- "gpt4_4edbafa2"
# group_3
- "7e00a6cb"
- "0e4e4c46"
- "gpt4_0b2f1d21"
- "55241a1f"
- "77eafa52"
- "dc439ea3"
- "86f00804"
- "830ce83f"
- "cc5ded98"
- "2311e44b_abs"
- "gpt4_d12ceb0e"
- "71a3fd6b"
- "07741c45"
- "e6041065"
- "gpt4_68e94288"
- "32260d93"
- "db467c8c"
- "4adc0475"
- "6cb6f249"
- "c9f37c46"
- "4388e9dd"
- "9ea5eabc"
- "gpt4_fe651585_abs"
- "9a707b81"
- "e9327a54"
- "eac54adc"
- "gpt4_7fce9456"
- "f9e8c073"
- "ba358f49_abs"
- "561fabcd"
- "42ec0761"
- "gpt4_4929293b"
- "c19f7a0b"
- "0bc8ad93"
- "e47becba"
- "15745da0"
- "2e6d26dc"
- "efc3f7c2"
- "37f165cf"
- "gpt4_f420262d"
- "66f24dbb"
- "gpt4_194be4b3"
- "gpt4_c27434e8_abs"
- "f4f1d8a4"
- "a89d7624"
- "19b5f2b3"
- "gpt4_8279ba02"
- "gpt4_74aed68e"
- "1da05512"
- "gpt4_f420262c"
# group_4
- "gpt4_fa19884d"
- "6f9b354f"
- "a3045048"
- "81507db6"
- "61f8c8f8"
- "a346bb18"
- "87f22b4a"
- "gpt4_2ba83207"
- "01493427"
- "08e075c7"
- "09d032c9"
- "6a27ffc2"
- "edced276"
- "8550ddae"
- "e5ba910e_abs"
- "3f1e9474"
- "8a2466db"
- "gpt4_2f8be40d"
- "2698e78f"
- "gpt4_1a1dc16d"
- "6b168ec8"
- "d905b33f"
- "60472f9c"
- "6e984301"
- "gpt4_1e4a8aeb"
- "gpt4_98f46fc6"
- "e66b632c"
- "gpt4_1d4ab0c9"
- "gpt4_0a05b494"
- "72e3ee87"
- "16c90bf4"
- "5809eb10"
- "e8a79c70"
- "gpt4_213fd887"
- "0ddfec37_abs"
- "gpt4_cd90e484"
- "06f04340"
- "eeda8a6d"
- "9a707b82"
- "29f2956b"
- "09ba9854_abs"
- "gpt4_5dcc0aab"
- "gpt4_7bc6cf22"
- "5d3d2817"
- "4c36ccef"
- "e3fc4d6e"
- "aae3761f"
- "d596882b"
- "ce6d2d27"
- "ad7109d1"
# group_5
- "76d63226"
- "18dcd5a5"
- "e48988bc"
- "099778bb"
- "2c63a862"
- "gpt4_7abb270c"
- "f523d9fe"
- "06878be2"
- "6456829e_abs"
- "faba32e5"
- "gpt4_15e38248"
- "gpt4_8e165409"
- "35a27287"
- "gpt4_59c863d7"
- "gpt4_59149c77"
- "8ebdbe50"
- "f685340e"
- "21d02d0d"
- "4fd1909e"
- "3249768e"
- "gpt4_f49edff3"
- "1d4e3b97"
- "1192316e"
- "e61a7584"
- "0100672e"
- "8cf51dda"
- "95bcc1c8"
- "71017277"
- "a3838d2b"
- "c4ea545c"
- "2318644b"
- "18bc8abd"
- "gpt4_61e13b3c"
- "gpt4_e05b82a6"
- "b46e15ed"
- "3a704032"
- "eac54add"
- "9ee3ecd6"
- "80ec1f4f"
- "gpt4_1916e0ea"
- "b6019101"
- "45dc21b6"
- "gpt4_c27434e8"
- "bc149d6b"
- "6222b6eb"
- "06db6396"
- "29f2956b_abs"
- "gpt4_5438fa52"
- "cf22b7bf"
- "853b0a1d"
runtime:
source_repo: .
source_ref: HEAD # Committed source only; resolved and saved as a full SHA.
config: benchmark.yaml
plugins: [lme]
candidate:
paths: [plugins/lme/src]
debug:
enabled: true
sessions_per_case: 3
messages_per_session: 7

View file

@ -0,0 +1,14 @@
# Evaluate the complement of the source experiment's training IDs in the full dataset.
# With the default training workspace this selects held-out groups 6-10 (250 cases).
source_workspace: benchmark/longmemeval/workspaces/home-groups-1-5
test_workspace: benchmark/longmemeval/workspaces/home-test-held-out
code_commit: "" # Required: set a candidate SHA/prefix, or pass --commit.
exclude_training_cases: true
benchmark:
name: longmemeval
source: benchmark/longmemeval/dataset/longmemeval_s_reme_cleaned.json
all_cases: true
evaluation:
concurrency: 4
timeout_seconds: 7200
record_history: false

View file

@ -0,0 +1,500 @@
{"case_id":"e47becba","evidence_sessions":["answer_280352e9"],"is_certain":true}
{"case_id":"118b2229","evidence_sessions":["answer_40a90d51"],"is_certain":true}
{"case_id":"51a45a95","evidence_sessions":["answer_d61669c7"],"is_certain":true}
{"case_id":"58bf7951","evidence_sessions":["answer_355c48bb"],"is_certain":true}
{"case_id":"1e043500","evidence_sessions":["answer_3e012175"],"is_certain":true}
{"case_id":"c5e8278d","evidence_sessions":["answer_f6168136"],"is_certain":true}
{"case_id":"6ade9755","evidence_sessions":["answer_9398da02"],"is_certain":true}
{"case_id":"6f9b354f","evidence_sessions":["answer_feb5200f"],"is_certain":true}
{"case_id":"58ef2f1c","evidence_sessions":["answer_59547700"],"is_certain":true}
{"case_id":"f8c5f88b","evidence_sessions":["answer_c3567066"],"is_certain":true}
{"case_id":"5d3d2817","evidence_sessions":["answer_235eb6fb"],"is_certain":true}
{"case_id":"7527f7e2","evidence_sessions":["answer_7cb94507"],"is_certain":true}
{"case_id":"c960da58","evidence_sessions":["answer_e05e4612"],"is_certain":true}
{"case_id":"3b6f954b","evidence_sessions":["answer_94030872"],"is_certain":true}
{"case_id":"726462e0","evidence_sessions":["answer_f38f679b"],"is_certain":true}
{"case_id":"94f70d80","evidence_sessions":["answer_c63c0458"],"is_certain":true}
{"case_id":"66f24dbb","evidence_sessions":["answer_fea2e4d3"],"is_certain":true}
{"case_id":"ad7109d1","evidence_sessions":["answer_679840f8"],"is_certain":true}
{"case_id":"af8d2e46","evidence_sessions":["answer_82a04f59"],"is_certain":true}
{"case_id":"dccbc061","evidence_sessions":["answer_8f276838"],"is_certain":true}
{"case_id":"c8c3f81d","evidence_sessions":["answer_761acef8"],"is_certain":true}
{"case_id":"8ebdbe50","evidence_sessions":["answer_8ad8a34f"],"is_certain":true}
{"case_id":"6b168ec8","evidence_sessions":["answer_e623ae87"],"is_certain":true}
{"case_id":"75499fd8","evidence_sessions":["answer_723bf11f"],"is_certain":true}
{"case_id":"21436231","evidence_sessions":["answer_1e6d4567"],"is_certain":true}
{"case_id":"95bcc1c8","evidence_sessions":["answer_cb742a61"],"is_certain":true}
{"case_id":"0862e8bf","evidence_sessions":["answer_c6fd8ebd"],"is_certain":true}
{"case_id":"853b0a1d","evidence_sessions":["answer_69811d4a"],"is_certain":true}
{"case_id":"a06e4cfe","evidence_sessions":["answer_6fe9fb49"],"is_certain":true}
{"case_id":"37d43f65","evidence_sessions":["answer_55161935"],"is_certain":true}
{"case_id":"b86304ba","evidence_sessions":["answer_645b0329"],"is_certain":true}
{"case_id":"d52b4f67","evidence_sessions":["answer_0df6aa4b"],"is_certain":true}
{"case_id":"25e5aa4f","evidence_sessions":["answer_986de8c3"],"is_certain":true}
{"case_id":"caf9ead2","evidence_sessions":["answer_0714183a"],"is_certain":true}
{"case_id":"8550ddae","evidence_sessions":["answer_c8354ae9"],"is_certain":true}
{"case_id":"60d45044","evidence_sessions":["answer_9cddca88"],"is_certain":true}
{"case_id":"3f1e9474","evidence_sessions":["answer_57fc1954"],"is_certain":true}
{"case_id":"86b68151","evidence_sessions":["answer_dc11c1eb"],"is_certain":true}
{"case_id":"577d4d32","evidence_sessions":["answer_0dd4d99a"],"is_certain":true}
{"case_id":"ec81a493","evidence_sessions":["answer_ed1982fc"],"is_certain":true}
{"case_id":"15745da0","evidence_sessions":["answer_586de428"],"is_certain":true}
{"case_id":"e01b8e2f","evidence_sessions":["answer_5ca6cd28"],"is_certain":true}
{"case_id":"bc8a6e93","evidence_sessions":["answer_e6143162"],"is_certain":true}
{"case_id":"ccb36322","evidence_sessions":["answer_f1fbb330"],"is_certain":true}
{"case_id":"001be529","evidence_sessions":["answer_530960c1"],"is_certain":true}
{"case_id":"b320f3f8","evidence_sessions":["answer_5cc9b056"],"is_certain":true}
{"case_id":"19b5f2b3","evidence_sessions":["answer_5ff494b9"],"is_certain":true}
{"case_id":"4fd1909e","evidence_sessions":["answer_2952aee4"],"is_certain":true}
{"case_id":"545bd2b5","evidence_sessions":["answer_47ffab4c"],"is_certain":true}
{"case_id":"8a137a7f","evidence_sessions":["answer_15d63a22"],"is_certain":true}
{"case_id":"76d63226","evidence_sessions":["answer_bbdc7b0a"],"is_certain":true}
{"case_id":"86f00804","evidence_sessions":["answer_96b8c9ee"],"is_certain":true}
{"case_id":"8e9d538c","evidence_sessions":["answer_7bdcbd23"],"is_certain":true}
{"case_id":"311778f1","evidence_sessions":["answer_e40b054e"],"is_certain":true}
{"case_id":"c19f7a0b","evidence_sessions":["answer_f442ccbe"],"is_certain":true}
{"case_id":"4100d0a0","evidence_sessions":["answer_83c13ff9"],"is_certain":true}
{"case_id":"29f2956b","evidence_sessions":["answer_7cc5362f"],"is_certain":true}
{"case_id":"1faac195","evidence_sessions":["answer_d01949bf"],"is_certain":true}
{"case_id":"faba32e5","evidence_sessions":["answer_39b12014"],"is_certain":true}
{"case_id":"f4f1d8a4","evidence_sessions":["answer_f5b33470"],"is_certain":true}
{"case_id":"c14c00dd","evidence_sessions":["answer_304511ce"],"is_certain":true}
{"case_id":"36580ce8","evidence_sessions":["answer_93e1bd22"],"is_certain":true}
{"case_id":"3d86fd0a","evidence_sessions":["answer_19c24c11"],"is_certain":true}
{"case_id":"a82c026e","evidence_sessions":["answer_787e6a6d"],"is_certain":true}
{"case_id":"0862e8bf_abs","evidence_sessions":["answer_c6fd8ebd_abs"],"is_certain":true}
{"case_id":"15745da0_abs","evidence_sessions":["answer_586de428_abs"],"is_certain":true}
{"case_id":"bc8a6e93_abs","evidence_sessions":["answer_e6143162_abs"],"is_certain":true}
{"case_id":"19b5f2b3_abs","evidence_sessions":["answer_5ff494b9_abs"],"is_certain":true}
{"case_id":"29f2956b_abs","evidence_sessions":["answer_7cc5362f_abs"],"is_certain":true}
{"case_id":"f4f1d8a4_abs","evidence_sessions":["answer_f5b33470_abs"],"is_certain":true}
{"case_id":"0a995998","evidence_sessions":["answer_afa9873b_2","answer_afa9873b_3"],"is_certain":true}
{"case_id":"6d550036","evidence_sessions":["answer_ec904b3c_1","answer_ec904b3c_4","answer_ec904b3c_3","answer_ec904b3c_2"],"is_certain":false}
{"case_id":"gpt4_59c863d7","evidence_sessions":["answer_593bdffd_4","answer_593bdffd_1","answer_593bdffd_3","answer_593bdffd_2"],"is_certain":true}
{"case_id":"b5ef892d","evidence_sessions":["answer_a8b4290f_3","answer_a8b4290f_1","answer_a8b4290f_2"],"is_certain":true}
{"case_id":"e831120c","evidence_sessions":["answer_86c505e7_1","answer_86c505e7_2"],"is_certain":true}
{"case_id":"3a704032","evidence_sessions":["answer_c2204106_2","answer_c2204106_3","answer_c2204106_1"],"is_certain":false}
{"case_id":"gpt4_d84a3211","evidence_sessions":["answer_2880eb6c_2","answer_2880eb6c_1"],"is_certain":true}
{"case_id":"aae3761f","evidence_sessions":["answer_526354c8_1","answer_526354c8_3","answer_526354c8_2"],"is_certain":true}
{"case_id":"gpt4_f2262a51","evidence_sessions":["answer_55a6940c_3","answer_55a6940c_1","answer_55a6940c_2"],"is_certain":true}
{"case_id":"dd2973ad","evidence_sessions":["answer_f9de4602_2","answer_f9de4602_1"],"is_certain":true}
{"case_id":"c4a1ceb8","evidence_sessions":["answer_56d02cab_3","answer_56d02cab_4","answer_56d02cab_2","answer_56d02cab_1"],"is_certain":false}
{"case_id":"gpt4_a56e767c","evidence_sessions":["answer_cf9e3940_2","answer_cf9e3940_1","answer_cf9e3940_3"],"is_certain":true}
{"case_id":"6cb6f249","evidence_sessions":["answer_a4204937_2","answer_a4204937_1"],"is_certain":true}
{"case_id":"46a3abf7","evidence_sessions":["answer_c65042d7_1","answer_c65042d7_2","answer_c65042d7_3"],"is_certain":true}
{"case_id":"36b9f61e","evidence_sessions":["answer_ef74281f_2","answer_ef74281f_1","answer_ef74281f_3"],"is_certain":true}
{"case_id":"28dc39ac","evidence_sessions":["answer_8d015d9d_3","answer_8d015d9d_5","answer_8d015d9d_2","answer_8d015d9d_4","answer_8d015d9d_1"],"is_certain":true}
{"case_id":"gpt4_2f8be40d","evidence_sessions":["answer_e7b0637e_2","answer_e7b0637e_1","answer_e7b0637e_3"],"is_certain":true}
{"case_id":"2e6d26dc","evidence_sessions":["answer_fa526fc0_4","answer_fa526fc0_3","answer_fa526fc0_1","answer_fa526fc0_2"],"is_certain":true}
{"case_id":"gpt4_15e38248","evidence_sessions":["answer_8858d9dc_3","answer_8858d9dc_1","answer_8858d9dc_4","answer_8858d9dc_2"],"is_certain":true}
{"case_id":"88432d0a","evidence_sessions":["answer_733e443a_3","answer_733e443a_4","answer_733e443a_2","answer_733e443a_1"],"is_certain":false}
{"case_id":"80ec1f4f","evidence_sessions":["answer_990c8992_2","answer_990c8992_1","answer_990c8992_3"],"is_certain":true}
{"case_id":"d23cf73b","evidence_sessions":["answer_5a0d28f8_4","answer_5a0d28f8_2","answer_5a0d28f8_3","answer_5a0d28f8_1"],"is_certain":true}
{"case_id":"gpt4_7fce9456","evidence_sessions":["answer_a679a86a_5","answer_a679a86a_4","answer_a679a86a_2","answer_a679a86a_3","answer_a679a86a_1"],"is_certain":true}
{"case_id":"d682f1a2","evidence_sessions":["answer_c008e5df_1","answer_c008e5df_2","answer_c008e5df_3"],"is_certain":true}
{"case_id":"7024f17c","evidence_sessions":["answer_a21f3697_1","answer_a21f3697_2"],"is_certain":true}
{"case_id":"gpt4_5501fe77","evidence_sessions":["answer_203bf3fa_1","answer_203bf3fa_3","answer_203bf3fa_2"],"is_certain":false}
{"case_id":"gpt4_2ba83207","evidence_sessions":["answer_6a3b5c13_3","answer_6a3b5c13_1","answer_6a3b5c13_2","answer_6a3b5c13_4"],"is_certain":true}
{"case_id":"2318644b","evidence_sessions":["answer_eaa8e3ef_1","answer_eaa8e3ef_2"],"is_certain":true}
{"case_id":"2ce6a0f2","evidence_sessions":["answer_901a6763_2","answer_901a6763_4","answer_901a6763_1","answer_901a6763_3"],"is_certain":true}
{"case_id":"gpt4_d12ceb0e","evidence_sessions":["answer_2504635e_3","answer_2504635e_2","answer_2504635e_1"],"is_certain":true}
{"case_id":"00ca467f","evidence_sessions":["answer_39900a0a_3","answer_39900a0a_2","answer_39900a0a_1"],"is_certain":true}
{"case_id":"b3c15d39","evidence_sessions":["answer_05d808e6_1","answer_05d808e6_2"],"is_certain":true}
{"case_id":"gpt4_31ff4165","evidence_sessions":["answer_02b63d04_1","answer_02b63d04_5","answer_02b63d04_2","answer_02b63d04_3","answer_02b63d04_4"],"is_certain":true}
{"case_id":"eeda8a6d","evidence_sessions":["answer_3e5fea0e_1","answer_3e5fea0e_2"],"is_certain":true}
{"case_id":"2788b940","evidence_sessions":["answer_8f6b938d_1","answer_8f6b938d_3","answer_8f6b938d_2"],"is_certain":true}
{"case_id":"60bf93ed","evidence_sessions":["answer_e0956e0a_1","answer_e0956e0a_2"],"is_certain":true}
{"case_id":"9d25d4e0","evidence_sessions":["answer_fcff2dc4_2","answer_fcff2dc4_1","answer_fcff2dc4_3"],"is_certain":true}
{"case_id":"129d1232","evidence_sessions":["answer_1de862d6_1","answer_1de862d6_3","answer_1de862d6_2"],"is_certain":true}
{"case_id":"60472f9c","evidence_sessions":["answer_e7fe8c8b_1","answer_e7fe8c8b_2","answer_e7fe8c8b_3"],"is_certain":true}
{"case_id":"gpt4_194be4b3","evidence_sessions":["answer_3826dc55_1","answer_3826dc55_3","answer_3826dc55_5","answer_3826dc55_2","answer_3826dc55_4"],"is_certain":true}
{"case_id":"a9f6b44c","evidence_sessions":["answer_cc021f81_2","answer_cc021f81_3","answer_cc021f81_1"],"is_certain":true}
{"case_id":"d851d5ba","evidence_sessions":["answer_5cdf9bd2_2","answer_5cdf9bd2_1","answer_5cdf9bd2_3","answer_5cdf9bd2_4"],"is_certain":false}
{"case_id":"5a7937c8","evidence_sessions":["answer_4cef8a3c_3","answer_4cef8a3c_1","answer_4cef8a3c_2"],"is_certain":true}
{"case_id":"gpt4_ab202e7f","evidence_sessions":["answer_728deb4d_5","answer_728deb4d_2","answer_728deb4d_3","answer_728deb4d_1","answer_728deb4d_4"],"is_certain":true}
{"case_id":"gpt4_e05b82a6","evidence_sessions":["answer_6350aa4f_1","answer_6350aa4f_2","answer_6350aa4f_3","answer_6350aa4f_4"],"is_certain":true}
{"case_id":"gpt4_731e37d7","evidence_sessions":["answer_826d51da_3","answer_826d51da_4","answer_826d51da_2","answer_826d51da_1"],"is_certain":true}
{"case_id":"edced276","evidence_sessions":["answer_60e8941a_2","answer_60e8941a_1"],"is_certain":true}
{"case_id":"10d9b85a","evidence_sessions":["answer_e0585cb5_2","answer_e0585cb5_1"],"is_certain":true}
{"case_id":"e3038f8c","evidence_sessions":["answer_b6018747_2","answer_b6018747_4","answer_b6018747_1","answer_b6018747_3"],"is_certain":true}
{"case_id":"2b8f3739","evidence_sessions":["answer_23759615_2","answer_23759615_3","answer_23759615_1"],"is_certain":true}
{"case_id":"1a8a66a6","evidence_sessions":["answer_2bd23659_3","answer_2bd23659_2"],"is_certain":true}
{"case_id":"c2ac3c61","evidence_sessions":["answer_923c0221_1","answer_923c0221_2"],"is_certain":true}
{"case_id":"bf659f65","evidence_sessions":["answer_7726e7e9_1","answer_7726e7e9_3","answer_7726e7e9_2"],"is_certain":false}
{"case_id":"gpt4_372c3eed","evidence_sessions":["answer_35c5419d_3","answer_35c5419d_2","answer_35c5419d_1"],"is_certain":false}
{"case_id":"gpt4_2f91af09","evidence_sessions":["answer_669318cf_2","answer_669318cf_1","answer_669318cf_3"],"is_certain":true}
{"case_id":"81507db6","evidence_sessions":["answer_da3c1266_4","answer_da3c1266_1","answer_da3c1266_5","answer_da3c1266_2"],"is_certain":true}
{"case_id":"88432d0a_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"80ec1f4f_abs","evidence_sessions":["answer_990c8992_abs_2","answer_990c8992_abs_3","answer_990c8992_abs_1"],"is_certain":true}
{"case_id":"eeda8a6d_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"60bf93ed_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"edced276_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_372c3eed_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"8a2466db","evidence_sessions":["answer_edb03329"],"is_certain":true}
{"case_id":"06878be2","evidence_sessions":["answer_555dfb94"],"is_certain":true}
{"case_id":"75832dbd","evidence_sessions":["answer_d87a6ef8"],"is_certain":true}
{"case_id":"0edc2aef","evidence_sessions":["answer_d586e9cd"],"is_certain":true}
{"case_id":"35a27287","evidence_sessions":["answer_9b182436"],"is_certain":true}
{"case_id":"32260d93","evidence_sessions":["answer_0250ae1c"],"is_certain":true}
{"case_id":"195a1a1b","evidence_sessions":["answer_6dc4305e"],"is_certain":true}
{"case_id":"afdc33df","evidence_sessions":["answer_8549e5e0"],"is_certain":true}
{"case_id":"caf03d32","evidence_sessions":["answer_2fc6aabb"],"is_certain":true}
{"case_id":"54026fce","evidence_sessions":["answer_f7b22c66"],"is_certain":true}
{"case_id":"06f04340","evidence_sessions":["answer_92d5f7cd"],"is_certain":true}
{"case_id":"6b7dfb22","evidence_sessions":["answer_f6502d0f"],"is_certain":true}
{"case_id":"1a1907b4","evidence_sessions":["answer_719502eb"],"is_certain":true}
{"case_id":"09d032c9","evidence_sessions":["answer_b10dce5e"],"is_certain":true}
{"case_id":"38146c39","evidence_sessions":["answer_772472c8"],"is_certain":true}
{"case_id":"d24813b1","evidence_sessions":["answer_7c0ade93"],"is_certain":true}
{"case_id":"57f827a0","evidence_sessions":["answer_1bde8d3b"],"is_certain":true}
{"case_id":"95228167","evidence_sessions":["answer_5e613445"],"is_certain":true}
{"case_id":"505af2f5","evidence_sessions":["answer_f3164f2c"],"is_certain":true}
{"case_id":"75f70248","evidence_sessions":["answer_8ee04a2e"],"is_certain":true}
{"case_id":"d6233ab6","evidence_sessions":["answer_b0fac439"],"is_certain":true}
{"case_id":"1da05512","evidence_sessions":["answer_4d3be2ab"],"is_certain":true}
{"case_id":"fca70973","evidence_sessions":["answer_a1e169b1"],"is_certain":true}
{"case_id":"b6025781","evidence_sessions":["answer_8414cc57"],"is_certain":true}
{"case_id":"a89d7624","evidence_sessions":["answer_8f15ac24"],"is_certain":true}
{"case_id":"b0479f84","evidence_sessions":["answer_30f63ddb"],"is_certain":true}
{"case_id":"1d4e3b97","evidence_sessions":["answer_e6b6353d"],"is_certain":true}
{"case_id":"07b6f563","evidence_sessions":["answer_d03098f9"],"is_certain":true}
{"case_id":"1c0ddc50","evidence_sessions":["answer_8da8c7e0"],"is_certain":true}
{"case_id":"0a34ad58","evidence_sessions":["answer_cebb7159"],"is_certain":true}
{"case_id":"d3ab962e","evidence_sessions":["answer_5237bb0b_2","answer_5237bb0b_1"],"is_certain":true}
{"case_id":"2311e44b","evidence_sessions":["answer_bf633415_2","answer_bf633415_1"],"is_certain":true}
{"case_id":"cc06de0d","evidence_sessions":["answer_4fb01417_2","answer_4fb01417_1"],"is_certain":true}
{"case_id":"a11281a2","evidence_sessions":["answer_c69ee1f9_2","answer_c69ee1f9_1"],"is_certain":true}
{"case_id":"4f54b7c9","evidence_sessions":["answer_50940cb7_2","answer_50940cb7_1"],"is_certain":true}
{"case_id":"85fa3a3f","evidence_sessions":["answer_dcb18b9b_2","answer_dcb18b9b_1"],"is_certain":true}
{"case_id":"9aaed6a3","evidence_sessions":[],"is_certain":true}
{"case_id":"1f2b8d4f","evidence_sessions":["answer_d8454588_1","answer_d8454588_2"],"is_certain":true}
{"case_id":"e6041065","evidence_sessions":["answer_4eb6d671_2","answer_4eb6d671_1"],"is_certain":true}
{"case_id":"51c32626","evidence_sessions":["answer_58820c75_1","answer_58820c75_2"],"is_certain":false}
{"case_id":"d905b33f","evidence_sessions":["answer_85a77c48_2","answer_85a77c48_1"],"is_certain":true}
{"case_id":"7405e8b1","evidence_sessions":["answer_80323f3f_1","answer_80323f3f_2"],"is_certain":true}
{"case_id":"f35224e0","evidence_sessions":["answer_e9bb9500_2","answer_e9bb9500_1"],"is_certain":true}
{"case_id":"6456829e","evidence_sessions":["answer_743f03a1_2","answer_743f03a1_1"],"is_certain":true}
{"case_id":"a4996e51","evidence_sessions":["answer_feb5f98a_1","answer_feb5f98a_2"],"is_certain":true}
{"case_id":"3c1045c8","evidence_sessions":["answer_c8cc60d6_2","answer_c8cc60d6_1"],"is_certain":true}
{"case_id":"60036106","evidence_sessions":["answer_e552e1f9_1","answer_e552e1f9_2"],"is_certain":true}
{"case_id":"681a1674","evidence_sessions":["answer_3be95d43_1","answer_3be95d43_2"],"is_certain":true}
{"case_id":"e25c3b8d","evidence_sessions":["answer_6702277b_1","answer_6702277b_2"],"is_certain":true}
{"case_id":"4adc0475","evidence_sessions":["answer_6efce493_1","answer_6efce493_2"],"is_certain":true}
{"case_id":"4bc144e2","evidence_sessions":["answer_9ef115d4_1","answer_9ef115d4_2"],"is_certain":true}
{"case_id":"ef66a6e5","evidence_sessions":["answer_f7fd1029_2","answer_f7fd1029_1"],"is_certain":true}
{"case_id":"5025383b","evidence_sessions":["answer_688f9a3f_1","answer_688f9a3f_2"],"is_certain":true}
{"case_id":"a1cc6108","evidence_sessions":["answer_17dc2f5b_2","answer_17dc2f5b_1"],"is_certain":true}
{"case_id":"9ee3ecd6","evidence_sessions":["answer_66c23110_1"],"is_certain":true}
{"case_id":"3fdac837","evidence_sessions":["answer_419d21d5_2","answer_419d21d5_1"],"is_certain":true}
{"case_id":"91b15a6e","evidence_sessions":["answer_5404a208_1","answer_5404a208_2"],"is_certain":true}
{"case_id":"27016adc","evidence_sessions":["answer_a37bdf22_2","answer_a37bdf22_1"],"is_certain":true}
{"case_id":"720133ac","evidence_sessions":["answer_c9dfeaea_1","answer_c9dfeaea_2"],"is_certain":true}
{"case_id":"77eafa52","evidence_sessions":["answer_33c251f0_2","answer_33c251f0_1"],"is_certain":true}
{"case_id":"8979f9ec","evidence_sessions":["answer_35e36341_1","answer_35e36341_2"],"is_certain":true}
{"case_id":"0100672e","evidence_sessions":["answer_35c9798c_2","answer_35c9798c_1"],"is_certain":true}
{"case_id":"a96c20ee","evidence_sessions":["answer_ef84b994_1","answer_ef84b994_2"],"is_certain":true}
{"case_id":"92a0aa75","evidence_sessions":["answer_6cb8f792_1","answer_6cb8f792_2"],"is_certain":true}
{"case_id":"3fe836c9","evidence_sessions":["answer_1bb63ea5_2","answer_1bb63ea5_1"],"is_certain":true}
{"case_id":"1c549ce4","evidence_sessions":["answer_4cb841a8_1","answer_4cb841a8_2"],"is_certain":true}
{"case_id":"6c49646a","evidence_sessions":["answer_cc1ced21_2","answer_cc1ced21_1"],"is_certain":true}
{"case_id":"1192316e","evidence_sessions":["answer_e184e4c3_2","answer_e184e4c3_1"],"is_certain":true}
{"case_id":"0ea62687","evidence_sessions":["answer_dc5e537d_1","answer_dc5e537d_2"],"is_certain":true}
{"case_id":"67e0d0f2","evidence_sessions":["answer_3a5010af_2","answer_3a5010af_1"],"is_certain":true}
{"case_id":"bb7c3b45","evidence_sessions":["answer_de64539a_1","answer_de64539a_2"],"is_certain":true}
{"case_id":"ba358f49","evidence_sessions":["answer_cbd08e3c_1","answer_cbd08e3c_2"],"is_certain":true}
{"case_id":"61f8c8f8","evidence_sessions":["answer_872e8da2_2","answer_872e8da2_1"],"is_certain":true}
{"case_id":"60159905","evidence_sessions":["answer_75eca223_2","answer_75eca223_1"],"is_certain":true}
{"case_id":"ef9cf60a","evidence_sessions":["answer_87e3a1cb_1","answer_87e3a1cb_2"],"is_certain":true}
{"case_id":"73d42213","evidence_sessions":["answer_1881e7db_1","answer_1881e7db_2"],"is_certain":true}
{"case_id":"bc149d6b","evidence_sessions":["answer_92147866_1","answer_92147866_2"],"is_certain":true}
{"case_id":"099778bb","evidence_sessions":["answer_80d6d664_2","answer_80d6d664_1"],"is_certain":true}
{"case_id":"09ba9854","evidence_sessions":["answer_96c743d0_2","answer_96c743d0_1"],"is_certain":true}
{"case_id":"d6062bb9","evidence_sessions":["answer_23f3a657_2","answer_23f3a657_1"],"is_certain":true}
{"case_id":"157a136e","evidence_sessions":["answer_8de18468_2","answer_8de18468_1"],"is_certain":true}
{"case_id":"c18a7dc8","evidence_sessions":["answer_2e2085fa_2","answer_2e2085fa_1"],"is_certain":true}
{"case_id":"a3332713","evidence_sessions":["answer_16ece55f_2","answer_16ece55f_1"],"is_certain":true}
{"case_id":"55241a1f","evidence_sessions":["answer_fa08bf49_1","answer_fa08bf49_2"],"is_certain":true}
{"case_id":"a08a253f","evidence_sessions":["answer_47152166_2","answer_47152166_1"],"is_certain":true}
{"case_id":"f0e564bc","evidence_sessions":["answer_cfcf5340_1","answer_cfcf5340_2"],"is_certain":true}
{"case_id":"078150f1","evidence_sessions":["answer_254d8b09_1","answer_254d8b09_2"],"is_certain":true}
{"case_id":"8cf4d046","evidence_sessions":["answer_e2278b24_1","answer_e2278b24_2"],"is_certain":true}
{"case_id":"a346bb18","evidence_sessions":["answer_4934b2d7_2","answer_4934b2d7_1"],"is_certain":true}
{"case_id":"37f165cf","evidence_sessions":["answer_6b9b2b1e_2","answer_6b9b2b1e_1"],"is_certain":false}
{"case_id":"8e91e7d9","evidence_sessions":["answer_477ae455_1","answer_477ae455_2"],"is_certain":true}
{"case_id":"87f22b4a","evidence_sessions":["answer_f56e6152_2","answer_f56e6152_1"],"is_certain":true}
{"case_id":"e56a43b9","evidence_sessions":["answer_430d0a87_1","answer_430d0a87_2"],"is_certain":true}
{"case_id":"efc3f7c2","evidence_sessions":["answer_fc81d1a2_1","answer_fc81d1a2_2"],"is_certain":true}
{"case_id":"21d02d0d","evidence_sessions":["answer_2c637141_2","answer_2c637141_1"],"is_certain":true}
{"case_id":"2311e44b_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"6456829e_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"e5ba910e_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"a96c20ee_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"ba358f49_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"09ba9854_abs","evidence_sessions":["answer_96c743d0_abs_1","answer_96c743d0_abs_2"],"is_certain":true}
{"case_id":"gpt4_59149c77","evidence_sessions":["answer_d00ba6d0_1","answer_d00ba6d0_2"],"is_certain":true}
{"case_id":"gpt4_f49edff3","evidence_sessions":["answer_3e9fce53_1","answer_3e9fce53_2","answer_3e9fce53_3"],"is_certain":true}
{"case_id":"71017276","evidence_sessions":["answer_0b4a8adc_1"],"is_certain":true}
{"case_id":"b46e15ed","evidence_sessions":["answer_4bfcc250_4","answer_4bfcc250_3","answer_4bfcc250_2","answer_4bfcc250_1"],"is_certain":true}
{"case_id":"gpt4_fa19884c","evidence_sessions":["answer_ff201786_1","answer_ff201786_2"],"is_certain":true}
{"case_id":"0bc8ad92","evidence_sessions":["answer_f4ea84fb_3","answer_f4ea84fb_2","answer_f4ea84fb_1"],"is_certain":true}
{"case_id":"af082822","evidence_sessions":["answer_b51b6115_1"],"is_certain":true}
{"case_id":"gpt4_4929293a","evidence_sessions":["answer_add9b012_2","answer_add9b012_1"],"is_certain":true}
{"case_id":"gpt4_b5700ca9","evidence_sessions":["answer_a17423e7_1"],"is_certain":true}
{"case_id":"9a707b81","evidence_sessions":["answer_dba89487_2","answer_dba89487_1"],"is_certain":false}
{"case_id":"gpt4_1d4ab0c9","evidence_sessions":["answer_febde667_1","answer_febde667_2"],"is_certain":true}
{"case_id":"gpt4_e072b769","evidence_sessions":["answer_c19bd2bf_1"],"is_certain":true}
{"case_id":"0db4c65d","evidence_sessions":["answer_b9e32ff8_1","answer_b9e32ff8_2"],"is_certain":true}
{"case_id":"gpt4_1d80365e","evidence_sessions":["answer_661b711f_1","answer_661b711f_2"],"is_certain":true}
{"case_id":"gpt4_7f6b06db","evidence_sessions":["answer_5d8c99d3_1","answer_5d8c99d3_2","answer_5d8c99d3_3"],"is_certain":true}
{"case_id":"gpt4_6dc9b45b","evidence_sessions":["answer_c4df007f_1"],"is_certain":true}
{"case_id":"gpt4_8279ba02","evidence_sessions":["answer_56521e65_1"],"is_certain":true}
{"case_id":"gpt4_18c2b244","evidence_sessions":["answer_c862f65a_2","answer_c862f65a_3","answer_c862f65a_1"],"is_certain":true}
{"case_id":"gpt4_a1b77f9c","evidence_sessions":["answer_e9ad5914_1","answer_e9ad5914_2","answer_e9ad5914_3","answer_e9ad5914_4","answer_e9ad5914_5","answer_e9ad5914_6"],"is_certain":false}
{"case_id":"gpt4_1916e0ea","evidence_sessions":["answer_447052a5_2","answer_447052a5_1"],"is_certain":true}
{"case_id":"gpt4_7a0daae1","evidence_sessions":["answer_4d5490f1_1","answer_4d5490f1_2"],"is_certain":true}
{"case_id":"gpt4_468eb063","evidence_sessions":["answer_9b09d95b_1"],"is_certain":true}
{"case_id":"gpt4_7abb270c","evidence_sessions":["answer_7093d898_1","answer_7093d898_2","answer_7093d898_3","answer_7093d898_4","answer_7093d898_5","answer_7093d898_6"],"is_certain":false}
{"case_id":"gpt4_1e4a8aeb","evidence_sessions":["answer_16bd5ea5_1","answer_16bd5ea5_2"],"is_certain":true}
{"case_id":"gpt4_4fc4f797","evidence_sessions":["answer_be07688f_1","answer_be07688f_2"],"is_certain":true}
{"case_id":"4dfccbf7","evidence_sessions":["answer_4bebc782_1","answer_4bebc782_2"],"is_certain":true}
{"case_id":"gpt4_61e13b3c","evidence_sessions":["answer_e831a29f_1","answer_e831a29f_2"],"is_certain":true}
{"case_id":"gpt4_45189cb4","evidence_sessions":["answer_e6c20e52_3","answer_e6c20e52_2","answer_e6c20e52_1"],"is_certain":true}
{"case_id":"2ebe6c90","evidence_sessions":["answer_c9d35c00_1","answer_c9d35c00_2"],"is_certain":true}
{"case_id":"gpt4_e061b84f","evidence_sessions":["answer_8c64ce25_2","answer_8c64ce25_1","answer_8c64ce25_3"],"is_certain":true}
{"case_id":"370a8ff4","evidence_sessions":["answer_61d1be50_1","answer_61d1be50_2"],"is_certain":true}
{"case_id":"gpt4_d6585ce8","evidence_sessions":["answer_f999b05b_1","answer_f999b05b_4","answer_f999b05b_2","answer_f999b05b_5","answer_f999b05b_3"],"is_certain":true}
{"case_id":"gpt4_4ef30696","evidence_sessions":["answer_f964cea3_1","answer_f964cea3_2"],"is_certain":true}
{"case_id":"gpt4_ec93e27f","evidence_sessions":["answer_e3aa84c4_2","answer_e3aa84c4_1"],"is_certain":true}
{"case_id":"6e984301","evidence_sessions":["answer_88841f26_1","answer_88841f26_2"],"is_certain":true}
{"case_id":"8077ef71","evidence_sessions":["answer_0dd54b7c_1"],"is_certain":true}
{"case_id":"gpt4_f420262c","evidence_sessions":["answer_d8a1af6b_1","answer_d8a1af6b_2","answer_d8a1af6b_3","answer_d8a1af6b_4","answer_d8a1af6b_5"],"is_certain":true}
{"case_id":"gpt4_8e165409","evidence_sessions":["answer_d97db962_1","answer_d97db962_2"],"is_certain":true}
{"case_id":"gpt4_74aed68e","evidence_sessions":["answer_aed8cf17_1","answer_aed8cf17_2"],"is_certain":true}
{"case_id":"bcbe585f","evidence_sessions":["answer_43ba3965_1"],"is_certain":true}
{"case_id":"gpt4_21adecb5","evidence_sessions":["answer_1e2369c9_1","answer_1e2369c9_2"],"is_certain":true}
{"case_id":"5e1b23de","evidence_sessions":["answer_c18d480b_1"],"is_certain":true}
{"case_id":"gpt4_98f46fc6","evidence_sessions":["answer_5850de18_2","answer_5850de18_1"],"is_certain":true}
{"case_id":"gpt4_af6db32f","evidence_sessions":["answer_184c8f56_1"],"is_certain":true}
{"case_id":"eac54adc","evidence_sessions":["answer_0d4d0347_1","answer_0d4d0347_2"],"is_certain":true}
{"case_id":"gpt4_7ddcf75f","evidence_sessions":["answer_ad8a76cb_1"],"is_certain":true}
{"case_id":"gpt4_a2d1d1f6","evidence_sessions":["answer_f6d6e33f_1"],"is_certain":true}
{"case_id":"gpt4_85da3956","evidence_sessions":["answer_581ab834_1"],"is_certain":true}
{"case_id":"gpt4_b0863698","evidence_sessions":["answer_550bb2d1_1"],"is_certain":true}
{"case_id":"gpt4_68e94287","evidence_sessions":["answer_9793daa3_2","answer_9793daa3_1"],"is_certain":true}
{"case_id":"gpt4_e414231e","evidence_sessions":["answer_e28c1f0d_1","answer_e28c1f0d_2"],"is_certain":true}
{"case_id":"gpt4_7ca326fa","evidence_sessions":["answer_cf021b36_1","answer_cf021b36_2","answer_cf021b36_3"],"is_certain":true}
{"case_id":"gpt4_7bc6cf22","evidence_sessions":["answer_e22d6aef_1"],"is_certain":true}
{"case_id":"2ebe6c92","evidence_sessions":["answer_c9d35c00_1","answer_c9d35c00_2"],"is_certain":true}
{"case_id":"gpt4_e061b84g","evidence_sessions":["answer_8c64ce26_2","answer_8c64ce26_1","answer_8c64ce26_3"],"is_certain":true}
{"case_id":"71017277","evidence_sessions":["answer_0b4a8adc_1"],"is_certain":true}
{"case_id":"b46e15ee","evidence_sessions":["answer_4bfcc251_4","answer_4bfcc251_3","answer_4bfcc251_2","answer_4bfcc251_1"],"is_certain":true}
{"case_id":"gpt4_d6585ce9","evidence_sessions":["answer_f999b05c_1","answer_f999b05c_4","answer_f999b05c_2","answer_f999b05c_5","answer_f999b05c_3"],"is_certain":true}
{"case_id":"gpt4_1e4a8aec","evidence_sessions":["answer_16bd5ea6_1","answer_16bd5ea6_2"],"is_certain":true}
{"case_id":"gpt4_f420262d","evidence_sessions":["answer_d8a1af6c_1","answer_d8a1af6c_2","answer_d8a1af6c_5","answer_d8a1af6c_4","answer_d8a1af6c_3"],"is_certain":false}
{"case_id":"gpt4_59149c78","evidence_sessions":["answer_d00ba6d1_2"],"is_certain":true}
{"case_id":"gpt4_e414231f","evidence_sessions":["answer_e28c1f0e_1","answer_e28c1f0e_2"],"is_certain":true}
{"case_id":"gpt4_4929293b","evidence_sessions":["answer_add9b013_2","answer_add9b013_1"],"is_certain":true}
{"case_id":"gpt4_468eb064","evidence_sessions":["answer_9b09d95b_1"],"is_certain":true}
{"case_id":"gpt4_fa19884d","evidence_sessions":["answer_ff201787_1","answer_ff201787_2"],"is_certain":true}
{"case_id":"9a707b82","evidence_sessions":["answer_dba89488_2","answer_dba89488_1"],"is_certain":true}
{"case_id":"eac54add","evidence_sessions":["answer_0d4d0348_1","answer_0d4d0348_2"],"is_certain":true}
{"case_id":"4dfccbf8","evidence_sessions":["answer_4bebc783_1","answer_4bebc783_2"],"is_certain":true}
{"case_id":"0bc8ad93","evidence_sessions":["answer_f4ea84fc_3","answer_f4ea84fc_2","answer_f4ea84fc_1"],"is_certain":false}
{"case_id":"6e984302","evidence_sessions":["answer_88841f27_2"],"is_certain":true}
{"case_id":"gpt4_8279ba03","evidence_sessions":["answer_56521e66_1"],"is_certain":true}
{"case_id":"gpt4_b5700ca0","evidence_sessions":["answer_a17423e8_1"],"is_certain":true}
{"case_id":"gpt4_68e94288","evidence_sessions":["answer_9793daa4_2","answer_9793daa4_1"],"is_certain":true}
{"case_id":"gpt4_2655b836","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_2487a7cb","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_76048e76","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_2312f94c","evidence_sessions":[],"is_certain":true}
{"case_id":"0bb5a684","evidence_sessions":[],"is_certain":true}
{"case_id":"08f4fc43","evidence_sessions":["answer_6ea1541e_2","answer_6ea1541e_1"],"is_certain":true}
{"case_id":"2c63a862","evidence_sessions":["answer_d39b7977_1","answer_d39b7977_2"],"is_certain":true}
{"case_id":"gpt4_385a5000","evidence_sessions":[],"is_certain":true}
{"case_id":"2a1811e2","evidence_sessions":[],"is_certain":true}
{"case_id":"bbf86515","evidence_sessions":["answer_b3763b6b_1","answer_b3763b6b_2"],"is_certain":true}
{"case_id":"gpt4_5dcc0aab","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_0b2f1d21","evidence_sessions":[],"is_certain":true}
{"case_id":"f0853d11","evidence_sessions":["answer_932ea3bf_2","answer_932ea3bf_1"],"is_certain":true}
{"case_id":"gpt4_6ed717ea","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_70e84552","evidence_sessions":[],"is_certain":true}
{"case_id":"a3838d2b","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_93159ced","evidence_sessions":["answer_e5131a1b_2","answer_e5131a1b_1"],"is_certain":true}
{"case_id":"gpt4_2d58bcd6","evidence_sessions":["answer_3e11e0ae_1","answer_3e11e0ae_2"],"is_certain":true}
{"case_id":"gpt4_65aabe59","evidence_sessions":[],"is_certain":true}
{"case_id":"982b5123","evidence_sessions":[],"is_certain":true}
{"case_id":"b9cfe692","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_4edbafa2","evidence_sessions":[],"is_certain":true}
{"case_id":"c8090214","evidence_sessions":["answer_70dc7d08_2","answer_70dc7d08_1"],"is_certain":true}
{"case_id":"gpt4_483dd43c","evidence_sessions":[],"is_certain":true}
{"case_id":"e4e14d04","evidence_sessions":[],"is_certain":true}
{"case_id":"c9f37c46","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_2c50253f","evidence_sessions":["answer_9af4e346_1","answer_9af4e346_2"],"is_certain":true}
{"case_id":"dcfa8644","evidence_sessions":["answer_5e3eeb12_2","answer_5e3eeb12_1"],"is_certain":true}
{"case_id":"gpt4_b4a80587","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_9a159967","evidence_sessions":["answer_8a42fedf_2"],"is_certain":true}
{"case_id":"cc6d1ec1","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_8c8961ae","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_d9af6064","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_7de946e7","evidence_sessions":["answer_6a78e959_2","answer_6a78e959_1"],"is_certain":true}
{"case_id":"d01c6aa8","evidence_sessions":[],"is_certain":true}
{"case_id":"993da5e2","evidence_sessions":[],"is_certain":true}
{"case_id":"a3045048","evidence_sessions":["answer_016f6bd4_1"],"is_certain":true}
{"case_id":"gpt4_d31cdae3","evidence_sessions":["answer_8464304d_1","answer_8464304d_2"],"is_certain":true}
{"case_id":"gpt4_cd90e484","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_88806d6e","evidence_sessions":["answer_e60a93ff_1","answer_e60a93ff_2"],"is_certain":true}
{"case_id":"gpt4_4cd9eba1","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_93f6379c","evidence_sessions":["answer_544fe66c_2","answer_544fe66c_3"],"is_certain":true}
{"case_id":"b29f3365","evidence_sessions":["answer_436d4309_2","answer_436d4309_1"],"is_certain":true}
{"case_id":"gpt4_2f56ae70","evidence_sessions":[],"is_certain":true}
{"case_id":"6613b389","evidence_sessions":["answer_aaf71ce2_2","answer_aaf71ce2_1"],"is_certain":true}
{"case_id":"gpt4_78cf46a3","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_0a05b494","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_1a1dc16d","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_2f584639","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_213fd887","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_5438fa52","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_c27434e8","evidence_sessions":["answer_d8e33f5c_1","answer_d8e33f5c_2"],"is_certain":true}
{"case_id":"gpt4_fe651585","evidence_sessions":[],"is_certain":true}
{"case_id":"8c18457d","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_70e84552_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_93159ced_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"982b5123_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"c8090214_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_c27434e8_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"gpt4_fe651585_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"6a1eabeb","evidence_sessions":["answer_a25d4a91_1","answer_a25d4a91_2"],"is_certain":true}
{"case_id":"6aeb4375","evidence_sessions":["answer_3f9693b7_1","answer_3f9693b7_2"],"is_certain":true}
{"case_id":"830ce83f","evidence_sessions":["answer_0b1a0942_2"],"is_certain":true}
{"case_id":"852ce960","evidence_sessions":["answer_3a6f1e82_2"],"is_certain":true}
{"case_id":"945e3d21","evidence_sessions":["answer_6a4f8626_1","answer_6a4f8626_2"],"is_certain":true}
{"case_id":"d7c942c3","evidence_sessions":["answer_eecb10d9_1","answer_eecb10d9_2"],"is_certain":true}
{"case_id":"71315a70","evidence_sessions":["answer_c44b9df4_1","answer_c44b9df4_2"],"is_certain":true}
{"case_id":"89941a93","evidence_sessions":["answer_e1403127_1","answer_e1403127_2"],"is_certain":true}
{"case_id":"ce6d2d27","evidence_sessions":["answer_73540165_2"],"is_certain":true}
{"case_id":"9ea5eabc","evidence_sessions":["answer_02e66dec_1","answer_02e66dec_2"],"is_certain":true}
{"case_id":"07741c44","evidence_sessions":["answer_7e9ad7b4_1","answer_7e9ad7b4_2"],"is_certain":true}
{"case_id":"a1eacc2a","evidence_sessions":["answer_0eb23770_1","answer_0eb23770_2"],"is_certain":true}
{"case_id":"184da446","evidence_sessions":["answer_e2f4f947_1","answer_e2f4f947_2"],"is_certain":true}
{"case_id":"031748ae","evidence_sessions":["answer_8748f791_1","answer_8748f791_2"],"is_certain":true}
{"case_id":"4d6b87c8","evidence_sessions":["answer_766ab8da_1","answer_766ab8da_2"],"is_certain":true}
{"case_id":"0f05491a","evidence_sessions":["answer_d6d2eba8_2"],"is_certain":true}
{"case_id":"08e075c7","evidence_sessions":["answer_cdbe2250_1","answer_cdbe2250_2"],"is_certain":true}
{"case_id":"f9e8c073","evidence_sessions":["answer_b191df5b_1","answer_b191df5b_2"],"is_certain":true}
{"case_id":"41698283","evidence_sessions":["answer_c7ddc051_1","answer_c7ddc051_2"],"is_certain":true}
{"case_id":"2698e78f","evidence_sessions":["answer_9282283d_1","answer_9282283d_2"],"is_certain":true}
{"case_id":"b6019101","evidence_sessions":["answer_67074b4b_1","answer_67074b4b_2"],"is_certain":true}
{"case_id":"45dc21b6","evidence_sessions":["answer_07664d43_1","answer_07664d43_2"],"is_certain":true}
{"case_id":"5a4f22c0","evidence_sessions":["answer_b0f3dfff_1","answer_b0f3dfff_2"],"is_certain":true}
{"case_id":"6071bd76","evidence_sessions":["answer_4dac77cb_1","answer_4dac77cb_2"],"is_certain":true}
{"case_id":"e493bb7c","evidence_sessions":["answer_1a374afa_1","answer_1a374afa_2"],"is_certain":true}
{"case_id":"618f13b2","evidence_sessions":["answer_caf5b52e_1","answer_caf5b52e_2"],"is_certain":true}
{"case_id":"72e3ee87","evidence_sessions":["answer_d7de9a6a_1","answer_d7de9a6a_2"],"is_certain":true}
{"case_id":"c4ea545c","evidence_sessions":["answer_d3bf812b_1","answer_d3bf812b_2"],"is_certain":true}
{"case_id":"01493427","evidence_sessions":["answer_a7b44747_1","answer_a7b44747_2"],"is_certain":true}
{"case_id":"6a27ffc2","evidence_sessions":["answer_77f32504_1","answer_77f32504_2"],"is_certain":true}
{"case_id":"2133c1b5","evidence_sessions":["answer_52382508_1"],"is_certain":true}
{"case_id":"18bc8abd","evidence_sessions":["answer_fff743f5_1","answer_fff743f5_2"],"is_certain":true}
{"case_id":"db467c8c","evidence_sessions":["answer_611b6e83_1","answer_611b6e83_2"],"is_certain":true}
{"case_id":"7a87bd0c","evidence_sessions":["answer_d08a934d_1","answer_d08a934d_2"],"is_certain":true}
{"case_id":"e61a7584","evidence_sessions":["answer_f25c32f5_1","answer_f25c32f5_2"],"is_certain":true}
{"case_id":"1cea1afa","evidence_sessions":["answer_79c395a9_1","answer_79c395a9_2"],"is_certain":true}
{"case_id":"ed4ddc30","evidence_sessions":["answer_babbaccb_1","answer_babbaccb_2"],"is_certain":true}
{"case_id":"8fb83627","evidence_sessions":["answer_966cecbb_1","answer_966cecbb_2"],"is_certain":true}
{"case_id":"b01defab","evidence_sessions":["answer_8c0712af_1","answer_8c0712af_2"],"is_certain":true}
{"case_id":"22d2cb42","evidence_sessions":["answer_bcce0b73_1","answer_bcce0b73_2"],"is_certain":true}
{"case_id":"0e4e4c46","evidence_sessions":["answer_f2f998c7_1","answer_f2f998c7_2"],"is_certain":true}
{"case_id":"4b24c848","evidence_sessions":["answer_2cec623b_1","answer_2cec623b_2"],"is_certain":true}
{"case_id":"7e974930","evidence_sessions":["answer_c9f5693c_1","answer_c9f5693c_2"],"is_certain":true}
{"case_id":"603deb26","evidence_sessions":["answer_8afdebac_1","answer_8afdebac_2"],"is_certain":true}
{"case_id":"59524333","evidence_sessions":["answer_b28f2c7a_2"],"is_certain":true}
{"case_id":"5831f84d","evidence_sessions":["answer_8d63a897_1","answer_8d63a897_2"],"is_certain":true}
{"case_id":"eace081b","evidence_sessions":["answer_8a791264_2"],"is_certain":true}
{"case_id":"affe2881","evidence_sessions":["answer_90de9b4d_1","answer_90de9b4d_2"],"is_certain":true}
{"case_id":"50635ada","evidence_sessions":["answer_dcd74827_1","answer_dcd74827_2"],"is_certain":true}
{"case_id":"e66b632c","evidence_sessions":["answer_ac0140ce_1","answer_ac0140ce_2"],"is_certain":true}
{"case_id":"0ddfec37","evidence_sessions":["answer_a22b654d_1","answer_a22b654d_2"],"is_certain":true}
{"case_id":"f685340e","evidence_sessions":["answer_25df025b_1","answer_25df025b_2"],"is_certain":true}
{"case_id":"cc5ded98","evidence_sessions":["answer_a5b68517_1","answer_a5b68517_2"],"is_certain":true}
{"case_id":"dfde3500","evidence_sessions":["answer_35d6c0be_1","answer_35d6c0be_2"],"is_certain":true}
{"case_id":"69fee5aa","evidence_sessions":["answer_d6028d6e_1","answer_d6028d6e_2"],"is_certain":true}
{"case_id":"7401057b","evidence_sessions":["answer_94650bfa_1","answer_94650bfa_2"],"is_certain":true}
{"case_id":"cf22b7bf","evidence_sessions":["answer_ae3a122b_1","answer_ae3a122b_2"],"is_certain":true}
{"case_id":"a2f3aa27","evidence_sessions":["answer_5126c02d_1","answer_5126c02d_2"],"is_certain":true}
{"case_id":"c7dc5443","evidence_sessions":["answer_0cdbca92_1","answer_0cdbca92_2"],"is_certain":true}
{"case_id":"06db6396","evidence_sessions":["answer_da72b1b4_1","answer_da72b1b4_2"],"is_certain":true}
{"case_id":"3ba21379","evidence_sessions":["answer_cd345582_1","answer_cd345582_2"],"is_certain":true}
{"case_id":"9bbe84a2","evidence_sessions":["answer_c6a0c6c2_1","answer_c6a0c6c2_2"],"is_certain":true}
{"case_id":"10e09553","evidence_sessions":["answer_67be2c38_1","answer_67be2c38_2"],"is_certain":true}
{"case_id":"dad224aa","evidence_sessions":["answer_4a97ae40_2"],"is_certain":true}
{"case_id":"ba61f0b9","evidence_sessions":["answer_f377cda7_2"],"is_certain":true}
{"case_id":"42ec0761","evidence_sessions":["answer_e3892371_1","answer_e3892371_2"],"is_certain":true}
{"case_id":"5c40ec5b","evidence_sessions":["answer_1cb52d0a_1","answer_1cb52d0a_2"],"is_certain":true}
{"case_id":"c6853660","evidence_sessions":["answer_626e93c4_1","answer_626e93c4_2"],"is_certain":true}
{"case_id":"26bdc477","evidence_sessions":["answer_f762ad8d_1","answer_f762ad8d_2"],"is_certain":true}
{"case_id":"0977f2af","evidence_sessions":["answer_3bf5b73b_1","answer_3bf5b73b_2"],"is_certain":true}
{"case_id":"6aeb4375_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"031748ae_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"2698e78f_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"2133c1b5_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"0ddfec37_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"f685340e_abs","evidence_sessions":[],"is_certain":true}
{"case_id":"89941a94","evidence_sessions":["answer_e1403127_1","answer_e1403127_2"],"is_certain":true}
{"case_id":"07741c45","evidence_sessions":["answer_7e9ad7b4_1","answer_7e9ad7b4_2"],"is_certain":true}
{"case_id":"7161e7e2","evidence_sessions":["answer_sharegpt_5Lzox6N_0"],"is_certain":true}
{"case_id":"c4f10528","evidence_sessions":["answer_ultrachat_234453"],"is_certain":true}
{"case_id":"89527b6b","evidence_sessions":["answer_sharegpt_YkWn1Ne_0"],"is_certain":true}
{"case_id":"e9327a54","evidence_sessions":["answer_ultrachat_480665"],"is_certain":true}
{"case_id":"4c36ccef","evidence_sessions":["answer_ultrachat_448704"],"is_certain":true}
{"case_id":"6ae235be","evidence_sessions":["answer_sharegpt_IUWQYGQ_0"],"is_certain":true}
{"case_id":"7e00a6cb","evidence_sessions":["answer_ultrachat_370515"],"is_certain":true}
{"case_id":"1903aded","evidence_sessions":["answer_sharegpt_hA7AkP3_0"],"is_certain":true}
{"case_id":"ceb54acb","evidence_sessions":["answer_sharegpt_cGdjmYo_0"],"is_certain":true}
{"case_id":"f523d9fe","evidence_sessions":["answer_sharegpt_m2xJfjo_0"],"is_certain":true}
{"case_id":"0e5e2d1a","evidence_sessions":["answer_ultrachat_113156"],"is_certain":true}
{"case_id":"fea54f57","evidence_sessions":["answer_ultrachat_187684"],"is_certain":true}
{"case_id":"cc539528","evidence_sessions":["answer_ultrachat_374124"],"is_certain":true}
{"case_id":"dc439ea3","evidence_sessions":["answer_ultrachat_459954"],"is_certain":true}
{"case_id":"18dcd5a5","evidence_sessions":["answer_sharegpt_hn3IS1q_0"],"is_certain":true}
{"case_id":"488d3006","evidence_sessions":["answer_ultrachat_275993"],"is_certain":true}
{"case_id":"58470ed2","evidence_sessions":["answer_sharegpt_U4oCSfU_7"],"is_certain":true}
{"case_id":"8cf51dda","evidence_sessions":["answer_sharegpt_HFMn2ZX_0"],"is_certain":true}
{"case_id":"1d4da289","evidence_sessions":["answer_ultrachat_348449"],"is_certain":true}
{"case_id":"8464fc84","evidence_sessions":["answer_ultrachat_467053"],"is_certain":true}
{"case_id":"8aef76bc","evidence_sessions":["answer_ultrachat_563222"],"is_certain":true}
{"case_id":"71a3fd6b","evidence_sessions":["answer_ultrachat_417348"],"is_certain":true}
{"case_id":"2bf43736","evidence_sessions":["answer_sharegpt_2kpncbX_13"],"is_certain":true}
{"case_id":"70b3e69b","evidence_sessions":["answer_ultrachat_334948"],"is_certain":true}
{"case_id":"8752c811","evidence_sessions":["answer_sharegpt_6pWK9yx_0"],"is_certain":true}
{"case_id":"3249768e","evidence_sessions":["answer_sharegpt_CaxTGYP_0"],"is_certain":true}
{"case_id":"1b9b7252","evidence_sessions":["answer_ultrachat_115151"],"is_certain":true}
{"case_id":"1568498a","evidence_sessions":["answer_sharegpt_d6JJiqH_76"],"is_certain":true}
{"case_id":"6222b6eb","evidence_sessions":["answer_sharegpt_H9PiM5G_0"],"is_certain":true}
{"case_id":"e8a79c70","evidence_sessions":["answer_ultrachat_13075"],"is_certain":true}
{"case_id":"d596882b","evidence_sessions":["answer_ultrachat_252214"],"is_certain":true}
{"case_id":"e3fc4d6e","evidence_sessions":["answer_sharegpt_5m7gg5F_0"],"is_certain":true}
{"case_id":"51b23612","evidence_sessions":["answer_ultrachat_427265"],"is_certain":true}
{"case_id":"3e321797","evidence_sessions":["answer_ultrachat_94624"],"is_certain":true}
{"case_id":"e982271f","evidence_sessions":["answer_ultrachat_195444"],"is_certain":true}
{"case_id":"352ab8bd","evidence_sessions":["answer_sharegpt_NoDZzot_7"],"is_certain":true}
{"case_id":"fca762bc","evidence_sessions":["answer_ultrachat_39395"],"is_certain":true}
{"case_id":"7a8d0b71","evidence_sessions":["answer_sharegpt_i0tMT9q_9"],"is_certain":true}
{"case_id":"a40e080f","evidence_sessions":["answer_ultrachat_269020"],"is_certain":true}
{"case_id":"8b9d4367","evidence_sessions":["answer_ultrachat_289157"],"is_certain":true}
{"case_id":"5809eb10","evidence_sessions":["answer_sharegpt_4aJsGCH_0"],"is_certain":true}
{"case_id":"41275add","evidence_sessions":["answer_sharegpt_81riySf_0"],"is_certain":true}
{"case_id":"4388e9dd","evidence_sessions":["answer_sharegpt_qTi81nS_0"],"is_certain":true}
{"case_id":"4baee567","evidence_sessions":["answer_sharegpt_i9adwQn_0"],"is_certain":true}
{"case_id":"561fabcd","evidence_sessions":["answer_sharegpt_hChsWOp_97"],"is_certain":true}
{"case_id":"b759caee","evidence_sessions":["answer_sharegpt_2BSXlAr_0"],"is_certain":true}
{"case_id":"ac031881","evidence_sessions":["answer_sharegpt_GYqnAhC_190"],"is_certain":true}
{"case_id":"28bcfaac","evidence_sessions":["answer_ultrachat_446979"],"is_certain":true}
{"case_id":"16c90bf4","evidence_sessions":["answer_ultrachat_294807"],"is_certain":true}
{"case_id":"c8f1aeed","evidence_sessions":["answer_ultrachat_519486"],"is_certain":true}
{"case_id":"eaca4986","evidence_sessions":["answer_sharegpt_SS141vi_0"],"is_certain":true}
{"case_id":"c7cf7dfd","evidence_sessions":["answer_ultrachat_456407"],"is_certain":true}
{"case_id":"e48988bc","evidence_sessions":["answer_ultrachat_174360"],"is_certain":true}
{"case_id":"1de5cff2","evidence_sessions":["answer_ultrachat_440262"],"is_certain":true}
{"case_id":"65240037","evidence_sessions":["answer_ultrachat_403752"],"is_certain":true}
{"case_id":"778164c6","evidence_sessions":["answer_ultrachat_399000"],"is_certain":true}

View file

@ -0,0 +1,173 @@
"""Prepare the LongMemEval Home workspace, or optimize it with --optimize after preparation."""
import argparse
import asyncio
import json
import sys
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
import yaml
# Support direct execution from any working directory as well as module imports.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# pylint: disable=wrong-import-position
from reme_home.preparation.spec import PreparationSpec # noqa: E402
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
from reme_home.sampling import SamplingPolicy # noqa: E402
from reme.application import Application # noqa: E402
from reme.utils.env_utils import load_env # noqa: E402
# pylint: enable=wrong-import-position
DEFAULT_CONFIG = Path(__file__).with_name("config_home.yaml")
class RunHomeConfig(BaseModel):
"""Runner settings kept separate from Home's public preparation contract."""
model_config = ConfigDict(extra="forbid", strict=True)
workspace_dir: str = Field(min_length=1)
preparation: dict
sampling_policy: SamplingPolicy = Field(default_factory=SamplingPolicy)
validation_cycle_time_budget_minutes: float | None = Field(default=150, gt=0, allow_inf_nan=False)
max_evaluations: int = Field(default=10, ge=1)
memory_limit_mib: int | None = Field(default=None, ge=0)
def project_path(value: str, project_root: Path) -> Path:
"""Use repository-relative paths consistently regardless of the caller's cwd."""
if value.startswith("~"):
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
return (project_root / value).resolve()
def _load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
"""Resolve source paths and validate explicit Home preparation parameters."""
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
config = RunHomeConfig.model_validate(raw)
config.preparation.setdefault("candidate", {"paths": ["plugins/lme/src"]})
spec = PreparationSpec.model_validate(config.preparation)
spec.benchmark.source = str(project_path(spec.benchmark.source, project_root))
if spec.benchmark.evidence_source is not None:
spec.benchmark.evidence_source = str(project_path(spec.benchmark.evidence_source, project_root))
spec.runtime.source_repo = str(project_path(spec.runtime.source_repo, project_root))
workspace = project_path(workspace_dir if workspace_dir is not None else config.workspace_dir, project_root)
return workspace, spec.model_dump(mode="json"), config
def load_config(config_path: str | Path, *, workspace_dir: str | None = None, project_root: Path = PROJECT_ROOT):
"""Return resolved preparation inputs, preserving the preparation-only caller contract."""
workspace, spec, _ = _load_config(config_path, workspace_dir=workspace_dir, project_root=project_root)
return workspace, spec
async def _run(config_path, *, workspace_dir=None, optimize_workspace=False, instruction=None):
"""Apply the same runner configuration to each preparation or optimization lifecycle."""
workspace, spec, config = _load_config(config_path, workspace_dir=workspace_dir)
load_env(PROJECT_ROOT / ".env", override=False)
memory_options = {}
jobs = {}
if config.memory_limit_mib is not None:
memory_options = {
"candidate_gate_memory_limit_mib": config.memory_limit_mib,
"construction_equivalence_memory_limit_mib": config.memory_limit_mib,
}
for name, backend, wrapper in (
("home_optimize", "home_optimize_step", "home_optimizer"),
("home_diagnose", "home_diagnose_step", "home_diagnostic"),
("home_case_study", "home_case_study_step", "home_case_study"),
):
jobs[name] = {
"steps": [
{
"backend": backend,
"agent_wrapper": wrapper,
"memory_limit_mib": config.memory_limit_mib,
**({"concurrency": 4, "output_token_limit": 2000} if name == "home_case_study" else {}),
},
],
}
app = Application(
workspace_dir=str(workspace),
plugins=["home", JUDGE_PLUGINS[spec["benchmark"]["name"]]],
components={
"home.evaluation": {
"default": {"sampling_policy": config.sampling_policy.model_dump(mode="json"), **memory_options},
},
"agent_wrapper": {
"home_optimizer": {
"validation_cycle_time_budget_minutes": config.validation_cycle_time_budget_minutes,
"max_evaluations": config.max_evaluations,
},
},
},
jobs=jobs,
service={"backend": "cli"},
enable_logo=False,
log_to_file=False,
)
try:
await app.start()
if optimize_workspace:
arguments = {} if instruction is None else {"instruction": instruction}
response = await app.run_job("home_optimize", **arguments)
else:
response = await app.run_job("home_prepare", spec=spec)
if not response.success:
raise RuntimeError(response.answer)
return {"workspace_dir": str(workspace), **response.answer}
finally:
await app.close()
async def prepare(config_path: str | Path, *, workspace_dir: str | None = None) -> dict:
"""Prepare once without running evaluations or optimization."""
return await _run(config_path, workspace_dir=workspace_dir)
async def optimize(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir=None, instruction=None) -> dict:
"""Optimize an already prepared workspace using this config's actual model budgets."""
return await _run(config_path, workspace_dir=workspace_dir, optimize_workspace=True, instruction=instruction)
async def run(config_path: str | Path = DEFAULT_CONFIG, *, workspace_dir: str | None = None) -> dict:
"""Prepare through the same Home Job as the CLI, then close the application."""
return await prepare(config_path, workspace_dir=workspace_dir)
def main(default_config: Path = DEFAULT_CONFIG) -> None:
"""Run preparation or explicit local one-shot optimization with the benchmark config."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=default_config, help="Home runner YAML configuration")
parser.add_argument("--workspace-dir", help="Override workspace directory (absolute or project-relative)")
parser.add_argument("--optimize", action="store_true", help="Optimize an already prepared workspace")
parser.add_argument("--instruction", help="Optimization focus; requires --optimize")
args = parser.parse_args()
if args.instruction is not None and not args.optimize:
parser.error("--instruction requires --optimize")
if args.optimize:
result = asyncio.run(optimize(args.config, workspace_dir=args.workspace_dir, instruction=args.instruction))
print(json.dumps(result, ensure_ascii=False, indent=2))
return
result = asyncio.run(prepare(args.config, workspace_dir=args.workspace_dir))
print(
json.dumps(
{
"workspace_dir": result["workspace_dir"],
"case_count": result["dataset"]["case_count"],
"query_count": result["dataset"]["query_count"],
"source_commit": result["code"]["source_commit"],
"initial_commit": result["code"]["initial_commit"],
},
indent=2,
),
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,226 @@
"""Evaluate a LongMemEval Home candidate in an independent test workspace."""
import argparse
import asyncio
import json
import sys
from pathlib import Path
from pydantic import Field, model_validator
import yaml
# Support direct execution from any working directory as well as module imports.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# pylint: disable=wrong-import-position
from reme_home.code.repository import resolve_revision, validate_repository # noqa: E402
from reme_home.evaluation.artifacts import contained, read_json # noqa: E402
from reme_home.evaluation.component import blocking # noqa: E402
from reme_home.evaluation.inputs import EvaluationSpec, load_inputs # noqa: E402
from reme_home.judge import JUDGE_PLUGINS # noqa: E402
from reme_home.preparation.code import tree_fingerprint # noqa: E402
from reme_home.preparation.data.models import fingerprint # noqa: E402
from reme_home.preparation.evaluation_source import EvaluationSource # noqa: E402
from reme_home.preparation.spec import BenchmarkSpec, DebugSpec, SpecModel # noqa: E402
from reme.application import Application # noqa: E402
from reme.utils.env_utils import load_env # noqa: E402
# pylint: enable=wrong-import-position
DEFAULT_CONFIG = Path(__file__).with_name("config_home_eval.yaml")
class TestRunConfig(SpecModel):
"""Explicit dataset selection and execution options for one pinned candidate."""
source_workspace: str = Field(min_length=1)
test_workspace: str = Field(min_length=1)
code_commit: str = Field(min_length=4)
benchmark: BenchmarkSpec
exclude_training_cases: bool = False
evaluation: EvaluationSpec = Field(default_factory=lambda: EvaluationSpec(record_history=False))
@model_validator(mode="after")
def validate_test_mode(self):
"""Test runs score every prepared query and never consume optimizer budgets."""
options = self.evaluation
if any(
(
options.debug,
options.sampling,
options.record_history,
options.max_evaluations is not None,
options.sampling_policy is not None,
options.case_ids is not None,
options.query_ids is not None,
options.reuse_construction_from is not None,
),
):
raise ValueError(
"Test evaluation requires full scoring with record_history=false; select cases in benchmark",
)
if self.exclude_training_cases and (self.benchmark.name != "longmemeval" or not self.benchmark.all_cases):
raise ValueError("exclude_training_cases requires LongMemEval with all_cases=true")
return self
def project_path(value: str, project_root: Path) -> Path:
"""Resolve runner paths independently of the launching working directory."""
if value.startswith("~"):
raise ValueError("Use absolute or project-relative paths, not home-relative paths")
return (project_root / value).resolve()
def load_request(config_path, *, commit=None, source_workspace=None, test_workspace=None, project_root=PROJECT_ROOT):
"""Validate all source inputs before starting an application or writing test data."""
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
for key, value in (
("code_commit", commit),
("source_workspace", source_workspace),
("test_workspace", test_workspace),
):
if value is not None:
raw[key] = value
# Execution settings can be partial while retaining the test-only history default.
raw["evaluation"] = {"record_history": False, **raw.get("evaluation", {})}
config = TestRunConfig.model_validate(raw)
source_path = project_path(config.source_workspace, project_root)
target = project_path(config.test_workspace, project_root)
if target.is_relative_to(source_path) or source_path.is_relative_to(target):
raise ValueError("Test and source workspaces must be separate, non-nested directories")
if target.exists() and (
not target.is_dir() or (any(target.iterdir()) and not (target / "home/metadata/workspace.json").is_file())
):
raise ValueError("Use an empty test workspace or a compatible prepared Home test workspace")
source = EvaluationSource.load(source_path, config.code_commit)
benchmark = config.benchmark.model_copy(deep=True)
if benchmark.name != source.spec.benchmark.name:
raise ValueError("Test benchmark must match the source experiment")
benchmark.source = str(project_path(benchmark.source, project_root).resolve(strict=True))
if benchmark.evidence_source is not None:
benchmark.evidence_source = str(project_path(benchmark.evidence_source, project_root).resolve(strict=True))
if config.exclude_training_cases:
items = json.loads(Path(benchmark.source).read_text(encoding="utf-8"))
ids = [str(item["question_id"]) for item in items]
if len(ids) != len(set(ids)):
raise ValueError("Duplicate LongMemEval question IDs")
training = set(source.record["dataset"]["case_ids"])
if training - set(ids):
raise ValueError("Test dataset is missing training IDs; provide the original full dataset")
benchmark.case_ids = [case_id for case_id in ids if case_id not in training]
benchmark.all_cases = False
benchmark = BenchmarkSpec.model_validate(benchmark.model_dump())
spec = source.spec.model_copy(deep=True)
spec.benchmark = benchmark
spec.debug = DebugSpec(enabled=False)
return config, target, source, spec
def existing_preparation(home, source, spec):
"""Reopen only the same request with unchanged code, runtime and normalized data."""
marker = contained(home, "metadata/preparation.json")
if not marker.exists():
return None
record = read_json(marker)
expected = {**source.provenance, "request_fingerprint": fingerprint(spec.model_dump(mode="json"))}
if record.get("test_evaluation") != expected:
raise ValueError("Test workspace belongs to a different candidate or dataset; use a new workspace")
domain = yaml.safe_load(contained(home, "domain_spec.yaml").read_text(encoding="utf-8"))
if fingerprint(domain) != record["config_fingerprint"]:
raise ValueError("Test preparation configuration has changed")
repository, _ = validate_repository(home, record)
if (
resolve_revision(repository, "HEAD") != record["code"]["initial_commit"]
or tree_fingerprint(repository) != record["code"]["candidate_fingerprint"]
or tree_fingerprint(contained(home, "code/runtime", directory=True)) != source.provenance["runtime_fingerprint"]
):
raise ValueError("Prepared test code or runtime has changed")
load_inputs(home, EvaluationSpec(record_history=False))
return record
async def run_evaluation(
config_path,
*,
commit=None,
source_workspace=None,
test_workspace=None,
prepare_only=False,
expected_benchmark=None,
):
"""Prepare transactionally, then score through the normal application lifecycle."""
config, target, source, spec = await blocking(
load_request,
config_path,
commit=commit,
source_workspace=source_workspace,
test_workspace=test_workspace,
)
if expected_benchmark is not None and config.benchmark.name != expected_benchmark:
raise ValueError(f"This entry point requires benchmark.name={expected_benchmark}")
load_env(PROJECT_ROOT / ".env", override=False)
app = Application(
workspace_dir=str(target),
plugins=["home", JUDGE_PLUGINS[config.benchmark.name]],
service={"backend": "cli"},
enable_logo=False,
log_to_file=False,
)
try:
await app.start()
home = target / "home"
prepared = await blocking(existing_preparation, home, source, spec)
if prepared is None:
preparation = app.context.components["home.preparation"]["default"]
prepared = await preparation.prepare(spec=spec.model_dump(mode="json"), code_source=source)
result = {
"test_workspace": str(target),
**source.provenance,
"evaluation_commit_sha": prepared["code"]["initial_commit"],
"case_count": prepared["dataset"]["case_count"],
"query_count": prepared["dataset"]["query_count"],
}
if not prepare_only:
response = await app.run_job(
"home_evaluate",
**config.evaluation.model_dump(mode="json", exclude_unset=True),
)
if not response.success:
raise RuntimeError(response.answer)
result["evaluation"] = response.answer
return result
finally:
await app.close()
async def run(config_path=DEFAULT_CONFIG, **kwargs):
"""Score the selected commit in its own Home workspace."""
return await run_evaluation(config_path, expected_benchmark="longmemeval", **kwargs)
def main():
"""Explicit local one-shot entry point, with preparation-only service setup."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--commit", help="Candidate commit SHA or unambiguous hexadecimal prefix")
parser.add_argument("--source-workspace", help="Prepared optimization workspace")
parser.add_argument("--test-workspace", help="Independent test output workspace")
parser.add_argument("--prepare-only", action="store_true", help="Prepare for a later home_evaluate service call")
args = parser.parse_args()
result = asyncio.run(
run(
args.config,
commit=args.commit,
source_workspace=args.source_workspace,
test_workspace=args.test_workspace,
prepare_only=args.prepare_only,
),
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View file

@ -265,15 +265,14 @@ def collect_usage(target: list | None = None, receipt: Path | None = None):
def observe_usage(app) -> None:
"""Observe owned clients and distinguish native interrupted replies from answers."""
"""Observe owned clients and reject incomplete agent replies."""
for wrapper in app.context.components.get("agent_wrapper", {}).values():
original_reply = wrapper.reply
async def reply(*args, _reply=original_reply, **kwargs):
result = await _reply(*args, **kwargs)
last = result.get("last_message") or {}
if last.get("finished_reason") == "interrupted" or last.get("error") is not None:
raise RuntimeError("Native Agent was interrupted; see the application log.")
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
return result
wrapper.reply = reply

View file

@ -291,15 +291,14 @@ def observe_usage(app) -> None:
def observe_completion(app) -> None:
"""Keep native interrupted replies unscored; successful replies are unchanged."""
"""Keep incomplete native replies unscored; completed replies are unchanged."""
for wrapper in app.context.components.get("agent_wrapper", {}).values():
original = wrapper.reply
async def reply(inputs, *args, _reply=original, **kwargs):
result = await _reply(inputs, *args, **kwargs)
last = result.get("last_message") or {}
if str(last.get("finished_reason")) == "interrupted" or last.get("error") is not None:
raise RuntimeError("Native Agent was interrupted; its state remains saved.")
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
return result
wrapper.reply = reply

View file

@ -787,8 +787,10 @@ class ReMeBridge:
result = await self.agent_wrapper.reply(user_message, **reply_kwargs)
reply_text = result.get("result", "")
session_id = result.get("session_id", "")
from reme.steps.evolve._evolve import agent_reply_result_text
reply_text = agent_reply_result_text(result)
session_id = result["session_id"]
if session_id:
self.agent_session_id = session_id

View file

@ -1,5 +1,5 @@
# Build from repository root:
# docker build -f deploy/benchmark_docker/Dockerfile.base -t reme-benchmark-base:agentscope-2.0.9 .
# docker build -f deploy/benchmark_docker/Dockerfile.base -t reme-benchmark-base:agentscope-2.0.11 .
FROM python:3.11-slim
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
@ -18,7 +18,7 @@ RUN mkdir reme \
&& printf '__version__ = "0.0.0"\n' > reme/__init__.py \
&& python -m pip install '.[core]' \
&& python -m pip uninstall -y reme-ai \
&& python -c 'import agentscope; assert agentscope.__version__ == "2.0.9"' \
&& python -c 'import agentscope; assert agentscope.__version__ == "2.0.11"' \
&& rm -rf /tmp/reme-dependencies
WORKDIR /workspace

View file

@ -1,5 +1,5 @@
# Build from repository root with this Dockerfile-specific ignore file.
ARG BASE_IMAGE=reme-benchmark-base:agentscope-2.0.9
ARG BASE_IMAGE=reme-benchmark-base:agentscope-2.0.11
FROM ${BASE_IMAGE}
COPY . /opt/reme

View file

@ -2,14 +2,18 @@
This package runs ReMe benchmark jobs directly inside isolated AgentScope
Docker workspaces. It does not start the ReMe HTTP service. Use AgentScope
**2.0.9** on the host and in the image.
**2.0.11** on the host and in the image.
Rebuild the base image when upgrading from an older AgentScope image;
existing images must also use 2.0.11 to pass candidate validation. New runs use
the `reme-benchmark-base:agentscope-2.0.11` tag by default, including Home evaluations.
From the repository root:
```bash
pip install -e '.[benchmark-docker]'
docker build -f deploy/benchmark_docker/Dockerfile.base \
-t reme-benchmark-base:agentscope-2.0.9 .
-t reme-benchmark-base:agentscope-2.0.11 .
```
`SourceCandidate` uploads an immutable source snapshot and installs the root
@ -57,6 +61,18 @@ case's workspace **as it stood immediately after construction**, or provide
`workspace_source` pointing at a host workspace directory or a saved archive.
A reused workspace may be queried without additional build jobs.
`export_workspace_to` persists a portable snapshot beyond the pool run.
`run(..., on_case_finished=callback)` awaits an async `(plan, result)` callback
after each case releases its worker, allowing incremental host-side persistence
and judging. Final worker-close errors can still be attached afterward; reconcile
returned results before publishing a completed summary. Callback errors abort the
batch and trigger worker cleanup. `fail_fast=True` stops the batch on an execution
failure. An optional `accept_build` predicate allows trusted callers to define
partial-build acceptance; the default requires successful construction.
`CasePlan.export_diagnostics_to` requests a best-effort full archive on execution
failure before resetting a still-accessible worker. Interrupted containers may
only retain a cached build log; failed diagnostic export never replaces the
original error. `CaseResult.failure_stage` and `exception` identify execution
failures separately from ordinary structured build/query results.
To build memory once and query it in later runs using the same factory:
```python
@ -137,6 +153,6 @@ explicitly enabled:
REME_DOCKER_INTEGRATION=1 pytest tests/integration/test_benchmark_docker.py -v
```
The host must have AgentScope 2.0.9 and a working Docker daemon. To use an
The host must have AgentScope 2.0.11 and a working Docker daemon. To use an
already built base image, set `REME_BENCHMARK_TEST_IMAGE` to its tag; the tests
do not remove a caller-supplied image.

View file

@ -146,7 +146,7 @@ class SourceCandidate:
"""
snapshot: SourceSnapshot
base_image: str = "reme-benchmark-base:agentscope-2.0.9"
base_image: str = "reme-benchmark-base:agentscope-2.0.11"
install_projects: tuple[str, ...] = (".",)
def __post_init__(self) -> None:

View file

@ -1,4 +1,4 @@
"""Minimal AgentScope 2.0.9 DockerWorkspace for direct ReMe jobs.
"""Minimal AgentScope 2.0.11 DockerWorkspace for direct ReMe jobs.
The stock workspace builds and starts an MCP gateway. The benchmark harness
uses only DockerBackend's exec/read/write primitives, so building that gateway

View file

@ -7,12 +7,13 @@ from dataclasses import asdict
from datetime import datetime, timezone
import gzip
import io
import inspect
import json
from pathlib import Path
from pathlib import PurePosixPath
import re
import tarfile
from typing import Any, BinaryIO, Callable, Literal, Protocol
from typing import Any, Awaitable, BinaryIO, Callable, Literal, Protocol
import uuid
from ._cleanup import cleanup_on_error
@ -239,13 +240,13 @@ def _archive_workspace(source: str | Path) -> bytes:
def _default_workspace_builder(workspace_id: str, image: str, env: dict[str, str]) -> Workspace:
"""Create the AgentScope 2.0.9 Docker backend lazily."""
"""Create the AgentScope 2.0.11 Docker backend lazily."""
import agentscope
from .direct_docker_workspace import DirectDockerWorkspace
if agentscope.__version__ != "2.0.9":
raise RuntimeError(f"benchmark Docker host requires agentscope 2.0.9, got {agentscope.__version__}")
if agentscope.__version__ != "2.0.11":
raise RuntimeError(f"benchmark Docker host requires agentscope 2.0.11, got {agentscope.__version__}")
return DirectDockerWorkspace(
workspace_id=workspace_id,
base_image=image,
@ -272,8 +273,12 @@ class DockerReMeSandboxFactory:
plugins: tuple[str, ...] | list[str] = (),
command_timeout: float = 1800.0,
workspace_builder: WorkspaceBuilder | None = None,
on_sandbox_started: Callable[[], None | Awaitable[None]] | None = None,
) -> None:
self.candidate = candidate
self._on_sandbox_started = on_sandbox_started
self._sandbox_started_reported = False
self._sandbox_started_lock = asyncio.Lock()
self.env = dict(env or {})
self.config = config
self.plugins = tuple(plugins)
@ -309,6 +314,13 @@ class DockerReMeSandboxFactory:
)
try:
await case.initialize()
if self._on_sandbox_started is not None:
async with self._sandbox_started_lock:
if not self._sandbox_started_reported:
result = self._on_sandbox_started()
if inspect.isawaitable(result):
await result
self._sandbox_started_reported = True
except BaseException as exc:
await cleanup_on_error([(f"workspace {container_id!r}", workspace.close())], exc)
raise
@ -489,9 +501,9 @@ class DockerReMeSandbox:
)
result = await self._exec_checked("validate_candidate", [python, "-c", script])
validation = json.loads(_decode(result.stdout))
if validation.get("agentscope") != "2.0.9":
if validation.get("agentscope") != "2.0.11":
raise SandboxCommandError(
f"candidate environment requires agentscope 2.0.9, got {validation.get('agentscope')!r}",
f"candidate environment requires agentscope 2.0.11, got {validation.get('agentscope')!r}",
)
candidate_root = PurePosixPath(CANDIDATE_ROOT)

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
@ -16,6 +17,19 @@ from typing import Any
from ._cleanup import finish_cleanup, format_error
from .docker_sandbox import DockerReMeSandbox, DockerReMeSandboxFactory
from .models import EvaluationQuery, JobRequest
from .worker import _exception_details
def _exception_classification(exc: BaseException) -> dict[str, Any]:
"""Retain only classification fields, omitting absent status codes and causes."""
details = _exception_details(exc)
result = {key: details[key] for key in ("type", "module")}
status = details.get("status_code")
if isinstance(status, int) and not isinstance(status, bool):
result["status_code"] = status
if details["causes"]:
result["causes"] = details["causes"]
return result
@dataclass(frozen=True, slots=True)
@ -30,6 +44,7 @@ class CasePlan:
export_workspace_to: str | Path | None = None
export_build_log_to: str | Path | None = None
export_evaluation_to: str | Path | None = None
export_diagnostics_to: str | Path | None = None
@dataclass(slots=True)
@ -42,6 +57,8 @@ class CaseResult:
workspace_snapshot: Path | None = None
build_log: str | None = None
error: str | None = None
failure_stage: str | None = None
exception: dict[str, Any] | None = None
@property
def success(self) -> bool:
@ -79,7 +96,14 @@ class BenchmarkDockerPool:
self.factory = factory
self.concurrency = concurrency
async def run(self, plans: list[CasePlan]) -> list[CaseResult]:
async def run(
self,
plans: list[CasePlan],
*,
on_case_finished: Callable[[CasePlan, CaseResult], Awaitable[None]] | None = None,
accept_build: Callable[[dict[str, Any]], bool] | None = None,
fail_fast: bool = False,
) -> list[CaseResult]:
"""Return results in input order, closing every container on exit."""
if not plans:
return []
@ -105,8 +129,7 @@ class BenchmarkDockerPool:
# a container. The graph check above rules out deadlocks.
tasks: dict[str, asyncio.Task[CaseResult]] = {}
async def execute(plan: CasePlan) -> CaseResult:
result = CaseResult(plan.case_id)
async def execute(plan: CasePlan, result: CaseResult) -> CaseResult:
source = plan.workspace_source
if plan.reuse_workspace_from is not None:
parent = await tasks[plan.reuse_workspace_from]
@ -121,43 +144,83 @@ class BenchmarkDockerPool:
return CaseResult(plan.case_id, error=slots.exhaustion_error)
worker_results.setdefault(id(worker), []).append(result)
operation = "prepare"
try:
await worker.reset_case(plan.case_id)
if source is not None:
operation = "restore"
await worker.upload_workspace(source)
if plan.build_jobs:
operation = "construction"
try:
result.build = await worker.run_build(list(plan.build_jobs))
except Exception as exc:
result.error = format_error(exc)
result.failure_stage = operation
result.exception = _exception_classification(exc)
await self._retain_failed_build_log(worker, plan, result)
return result
if not result.build.get("success"):
accepted = accept_build(result.build) if accept_build else result.build.get("success")
if not accepted:
result.error = "build failed"
result.failure_stage = operation
await self._retain_failed_build_log(worker, plan, result)
return result
if plan.export_build_log_to is not None:
operation = "artifacts"
await worker.export_build_log(plan.export_build_log_to)
# Capture before queries, since answering may change the
# workspace. Dependents receive construction state.
if plan.export_workspace_to is not None or plan.case_id in reused_case_ids:
operation = "artifacts"
destination = plan.export_workspace_to or snapshot_root / f"{plan.case_id}.tar.gz"
result.workspace_snapshot = await worker.export_workspace(destination)
if plan.queries:
operation = "answer"
result.queries = await worker.run_queries(list(plan.queries))
if not result.queries.get("success"):
result.error = "query failed"
result.failure_stage = operation
if plan.export_evaluation_to is not None:
operation = "artifacts"
await worker.export_evaluation(plan.export_evaluation_to)
return result
except Exception as exc: # Keep other cases running and release this worker.
result.error = format_error(exc)
result.failure_stage = operation
result.exception = _exception_classification(exc)
return result
finally:
if result.error and plan.export_diagnostics_to is not None and worker.reusable:
try:
await worker.export_full(plan.export_diagnostics_to)
except Exception as exc:
result.error += f"; diagnostic export failed: {format_error(exc)}"
await self._release_worker(worker, result, slots)
async def run_plan(plan: CasePlan) -> CaseResult:
result = CaseResult(plan.case_id)
try:
result = await execute(plan, result)
except asyncio.CancelledError:
result.error = result.error or "interrupted"
raise
finally:
if on_case_finished is not None:
await on_case_finished(plan, result)
failed = not result.success
if accept_build is not None and result.build is not None:
failed = (
result.error is not None
or not accept_build(result.build)
or (result.queries is not None and not result.queries.get("success"))
)
if fail_fast and failed:
raise RuntimeError(f"case {plan.case_id!r} failed: {result.error or 'execution failed'}")
return result
for plan in plans:
tasks[plan.case_id] = asyncio.create_task(execute(plan))
tasks[plan.case_id] = asyncio.create_task(run_plan(plan))
try:
results = await asyncio.gather(*(tasks[plan.case_id] for plan in plans))
finally:

View file

@ -40,12 +40,30 @@ def _exception_details(exc: BaseException) -> dict[str, Any]:
"message": str(exc),
}
response = getattr(exc, "response", None)
status_code = getattr(response, "status_code", None)
status_code = getattr(exc, "status_code", None)
if not isinstance(status_code, int):
status_code = getattr(response, "status_code", None)
if isinstance(status_code, int):
details["status_code"] = status_code
error_number = getattr(exc, "errno", None)
if isinstance(error_number, int):
details["errno"] = error_number
causes = []
seen = {id(exc)}
current = exc
while True:
current = current.__cause__ or (None if current.__suppress_context__ else current.__context__)
if current is None or id(current) in seen:
break
seen.add(id(current))
cause = {"type": type(current).__name__, "module": type(current).__module__}
status = getattr(current, "status_code", None)
if not isinstance(status, int):
status = getattr(getattr(current, "response", None), "status_code", None)
if isinstance(status, int) and not isinstance(status, bool):
cause["status_code"] = status
causes.append(cause)
details["causes"] = causes
return details
@ -158,6 +176,9 @@ async def _run_job_on_app(app: Any, job: str, arguments: dict[str, Any]) -> dict
"metadata": response.metadata,
"error": None if response.success else str(response.answer),
}
exception = response.metadata.get("reme.job_error")
if not response.success and isinstance(exception, dict):
result["exception"] = exception
result["token_usage"] = token_usage
result["job_call_counts"] = {name: count for name, count in job_call_counts.items() if count}
return result

View file

@ -557,6 +557,29 @@ Current default components in `reme/config/default.yaml`:
| `keyword_index` | `default` | `bm25` | BM25 keyword index. |
| `file_store` | `default` | `local` | Combines file_graph and keyword_index; defaults to `embedding_store: ""`. |
All three agent backends execute through `reply_stream()`. `reply()` collects that same stream into an
[`AgentReply`](../../reme/schema/agent_reply.py) dictionary: `session_id`, final text in `result`,
`structured_output`, invocation-scoped `usage`, `status`, `error`, `raw`, and the complete ordered JSON `events`.
Both modes accept `output_schema`. Only `completed` is successful; `failed`, `interrupted`, and `waiting` preserve
incomplete outcomes. Setup/transport errors and caller cancellation propagate. Both modes record reported token
usage once per invocation; unavailable usage remains null.
[`StreamChunk`](../../reme/schema/stream_chunk.py) uses `start`, append-only `delta`, and `end` operations.
Non-null start/end payloads replace a block's accumulated content; null leaves it unchanged, and an empty string
clears it. Session, turn, message, block, and tool-call IDs correlate updates. `metadata.native` retains complete
provider events, including unknown fields and diagnostic notifications. `reply_end.chunk` carries the outcome
fields above, excluding `events`; `reply_start`/`reply_end` delimit one invocation, while DONE only closes transport.
`collect_reply(stream)` and `replay_reply(reply)` convert between the async stream and JSON result without losing
payloads, order, native metadata, or terminal state. Collection fails if the terminal outcome is missing or repeated.
`raw` preserves native results: AgentScope's final message in `raw.message` plus an optional
`raw.structured_response`, Claude's `ResultMessage`, or Codex's `TurnResult`. Persist the full AgentReply for exact
replay; native session history supplies item snapshots rather than the original stream's deltas.
Studio reconciles block snapshots, displays structured output and terminal states, and retains live events through
history refreshes. Raw HTTP `stream_chunks()` includes errors and DONE; CLI/text helpers handle errors separately.
Migration: replace `last_message`/`turn` with `result`/`raw`, and consume the terminal outcome instead of concatenating
all stream payloads. Existing text-only producers use the default `delta` operation.
Codex wrappers reference `app_server: default` or `app_server: oauth`. Multiple wrappers can share one server while
keeping their own models, instructions and conversations. Omit `session_id` to start a new thread; pass a returned
`session_id` to continue it. Different threads can execute concurrently; operations on one thread are serialized.

View file

@ -538,6 +538,26 @@ flowchart LR
| `keyword_index` | `default` | `bm25` | BM25 关键词索引 |
| `file_store` | `default` | `local` | 组合 file_graph、keyword_index;默认 `embedding_store: ""` |
三个 agent 后端统一通过 `reply_stream()` 执行,`reply()` 收集同一条流,返回
[`AgentReply`](../../reme/schema/agent_reply.py) 字典:`session_id`、最终文本 `result`、`structured_output`、
本次调用的 `usage`、`status`、`error`、`raw` 和完整、有序的 JSON `events`。两种模式均支持 `output_schema`。
只有 `completed` 表示成功;`failed`、`interrupted` 和 `waiting` 保留未完成的结果。启动、传输异常和调用方的
取消继续传播。两种模式均按调用记录一次已报告的 token 用量;无法获取用量时为 null。
[`StreamChunk`](../../reme/schema/stream_chunk.py) 使用 `start`、追加内容的 `delta` 和 `end` 操作。
start/end 的非 null 内容替换对应块的累计内容;null 表示不变,空字符串表示清空。会话、调用、消息、内容块和
工具调用 ID 用于关联更新。`metadata.native` 保留完整原生事件,包括未知字段和诊断通知。
`reply_end.chunk` 携带上述结果字段,不含 `events`;`reply_start`/`reply_end` 包含一次 agent 调用内的模型与工具
循环,DONE 仅关闭传输。`collect_reply(stream)` 与 `replay_reply(reply)` 在异步流和 JSON 结果间无损转换,保留
payload、顺序、原生元数据和终止状态;缺少或重复的终止结果会明确报错。
`raw` 保留原生结果:AgentScope 的最终消息在 `raw.message`,额外结构化结果在 `raw.structured_response`;
Claude 保留 `ResultMessage`,Codex 保留 `TurnResult`。需要原样重放时应保存完整 AgentReply;原生会话历史提供
item 快照,不含原始流的逐次增量。Studio 合并内容快照,展示结构化输出和终止状态,历史刷新保留已有实时事件。
HTTP `stream_chunks()` 完整返回错误事件和 DONE,CLI 与文本辅助函数单独处理报错。
迁移:用 `result`/`raw` 替代 `last_message`/`turn`,消费终止结果,不再拼接所有流式 payload。
纯文本生产者使用默认 `delta` 操作。
Codex wrapper 通过 `app_server: default` 或 `app_server: oauth` 引用共享组件。多个 wrapper 可以共享同一个进程,
并分别配置模型、指令和会话。不传 `session_id` 时创建新 thread;传回已有 `session_id` 时续聊。
不同 thread 可以并发,同一 thread 的操作串行。

View file

@ -4,7 +4,7 @@
This guide explains how to install, configure, and use `@agentscope-ai/reme-dsh-plugin` with DeepSeek Harness (DSH), including memory guidance injection, the `reme_search` tool, automatic memory, daily consolidation, and the ReMe Status page.
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, and `0.2.1-rc.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, and `0.2.2-alpha.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
## 1. How the plugin works
@ -26,8 +26,8 @@ The DSH adapter injects **usage guidance**, not every historical memory. Relevan
## 2. Requirements
- ReMe is installed and its configuration exposes the `search`, `auto_memory`, and `auto_dream` jobs.
- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, or `0.2.1-rc.2`.
- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range.
- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, or `0.2.2-alpha.2`.
- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range (DSH `0.2.2` needs Node 24.9 or newer).
- The browser running DSH can reach the configured ReMe HTTP endpoint. Cross-machine deployments must also allow the DSH browser origin.
The default ReMe endpoint is `http://127.0.0.1:2333`. ReMe HTTP does not use API-key authentication, so do not expose it directly to an untrusted network.
@ -110,26 +110,43 @@ If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported
![ReMe Memory plugin configuration](./figures/reme-memory-settings.png)
| UI meaning | Configuration key | Default | Description |
| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------- |
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
| UI meaning | Configuration key | Default | Description |
| ---------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
| Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; set in the profile only (not shown in this form), stored in clear text, trusted networks only. |
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
Deployment configuration also supports `REME_URL`, or `REME_HOST` together with `REME_PORT`. The timer-only test option `dreamIntervalMs` is intentionally excluded from user settings.
## 4.1 Environment management (Environment tab)
The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers the setup that
otherwise needs a separate terminal, on the machine that runs DSH:
| Step | What it does |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Local Python | Finds PEP 514, `py` launcher, `PATH` and conda interpreters, labels each origin and `reme-ai` state, stores `pythonPath` |
| ReMe dependency | Checks `reme-ai`, the `[core]` extras and the `reme` CLI; installs or upgrades `reme-ai[core]` with a live log and cancel |
| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked; provides an LLM connectivity test |
| Service control | Starts and stops the local ReMe service, showing PID, version and log |
See the [Environment management guide](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment.md)
for interpreter discovery and the recommendation rule, dependency installation, `.env` validation, connection
testing, service lifetime, platform support and troubleshooting.
## 5. Memory context injection
On `agent/created`, the plugin injects long-term-memory guidance as native plugin context. Expand **Context injection · reme-memory** in the message flow to inspect both the content and provenance.

View file

@ -26,8 +26,8 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记
## 2. 环境要求
- ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。
- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4` 或 `0.2.1-rc.2`。
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。
- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2` 或 `0.2.2-alpha.2`。
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致(DSH `0.2.2` 需要 Node 24.9 以上)。
- DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。
ReMe HTTP 服务默认监听 `http://127.0.0.1:2333`,不使用 API Key 认证。因此不建议未经网络隔离直接暴露到公网。
@ -115,26 +115,42 @@ dsh web --no-open --port 3090
截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下:
| 界面含义 | 配置键 | 默认值 | 说明 |
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------- |
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
| 界面含义 | 配置键 | 默认值 | 说明 |
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
| 服务令牌(可选) | `authToken` | 空 | 自部署网关的 Bearer 令牌;仅通过 profile 配置(表单中不显示),明文保存,仅建议内网/受信环境使用。 |
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
部署层还支持 `REME_URL`,或组合使用 `REME_HOST` 与 `REME_PORT`。仅供定时器测试的 `dreamIntervalMs` 不出现在用户设置中。
## 4.1 环境管理(Environment 标签)
**插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「找 Python、装 ReMe、写凭据、启停服务」收进插件本体,无需另开终端;
这些操作都发生在运行 DSH 的那台机器上:
| 步骤 | 做什么 |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| 本地 Python 环境 | 发现 PEP 514 注册表、`py` 启动器、`PATH` 与 conda 解释器,标注来源与 `reme-ai` 状态,选中即写入 `pythonPath` |
| ReMe 依赖 | 检测 `reme-ai`、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`,日志实时可见、可取消 |
| ReMe 参数 | 编辑 workspace 的 `.env`:密钥只保存在该文件、界面仅显示掩码;提供 LLM 连通性测试 |
| 服务控制 | 启停本机 ReMe 服务,显示 PID、版本与日志 |
解释器发现与推荐规则、依赖安装、`.env` 校验、连接测试、服务生命周期、平台支持与故障排查见
[环境管理指南](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment_ZH.md)。
## 5. 普通对话中的 memory 上下文注入
创建一个新会话后,插件监听 DSH 的 `agent/created`,把长期记忆使用规则作为一条原生 plugin context 注入。展开消息流中的 **上下文注入 · reme-memory** 可以直接检查内容与来源元数据。

View file

@ -0,0 +1,272 @@
# Environment management
The **Environment** tab is the setup surface for a local ReMe install. It finds a Python interpreter on the
machine that runs DSH, installs `reme-ai[core]` into it, writes the workspace `.env`, and starts or stops the
local ReMe service — the work that otherwise needs several terminals.
Open it from **Plugins → ReMe bundle → reme-memory → Configure → Environment**.
The tab manages a **loopback** endpoint at the origin root (`http://127.0.0.1:…` or `http://localhost:…`). Any
other service URL — a remote host, or a gateway mounted under a path prefix — is only reported as answering or
not, and hides the local steps.
Everything the tab reports comes from the DSH **Host**, not from the browser: Python discovery, file access,
the LLM probe and the service health check all run on the Host machine. A browser on another machine therefore
sees the Host's environment, and the hero and the service card can never disagree about it.
## What each step does
| Section | Capability |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Local Python | Discovers PEP 514 registry entries, the `py` launcher, `PATH` and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` |
| ReMe dependency | Checks the `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, a live log and cancel |
| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked. Saving requires a non-empty `EMBEDDING_API_KEY` (use `EMPTY` when vector search is off). Provides an LLM connectivity test |
| Service control | Starts and stops the local ReMe service, showing PID, version and log; the launch injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works |
The hero summarises the same state as three chips — service, Python, ReMe — plus the endpoint, the detection
time and the two refresh actions.
## Python discovery
ReMe needs **Python 3.11 or newer**. The tab probes these channels on the Host and keeps only interpreters that
answered its verification snippet (a `-c` probe reporting `sys.executable`, the version and the base prefix):
- Windows: PEP 514 registry entries (`HKLM`/`HKCU`), the `py` launcher list, `PATH`, and `conda env list`.
- macOS and Linux: `PATH` (`which -a`), the well-known `/usr/bin`, `/usr/local`, Homebrew (`/opt/homebrew`),
`~/.pyenv/shims` and `~/.local/bin` locations, and `conda env list`.
Microsoft Store placeholder links under `WindowsApps` are skipped, because they are shell aliases rather than
interpreters. When conda is present, named environments are listed both under `conda env list` and as
interpreters, and the conda root is reported separately.
Detection is cached for 60 seconds; **Refresh** (in the hero) probes again. The recommended interpreter is
chosen in this order:
1. A named conda environment (`base` excluded) that already has a supported `reme-ai`.
2. Another interpreter that already has a supported `reme-ai`.
3. Any named conda environment, which is ready for a ReMe install.
4. The first usable interpreter.
Hover the **recommended** tag to see which rule applied. Picking another interpreter writes `pythonPath` and
nothing else; leaving it unset keeps following the recommendation. The list shows six rows and expands on
demand, with a filter over version, origin and path.
## ReMe dependency
The check asks the selected interpreter three questions and reports them as metrics:
- `reme-ai` metadata version (`importlib.metadata.version("reme-ai")`), and whether it satisfies the minimum
this integration targets.
- `[core]` extras: whether `import agentscope` works. Supported ReMe releases import AgentScope while loading the
CLI, so it is required to start the service. Install or reinstall `reme-ai[core]` when it is missing.
- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time.
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes
depending on the network.
The installation is a Host-owned job:
- It keeps running when the configuration page closes, and reopening the tab restores it for the same
interpreter. The Host looks the job up by interpreter, so two interpreters installing at the same time each
see their own progress.
- Progress is polled by job id, so a second installation for another interpreter cannot replace what you see.
The install answer is bound to the interpreter that asked for it, so a late response after an interpreter
switch is dropped instead of showing another interpreter's job.
- A successful install re-checks `reme-ai` for that interpreter, so the dependency card and **Start service**
follow the install without a manual **Check**.
- **Cancel** stops pip and its build children, and the interpreter stays locked until the whole managed process
group has exited. If pip exits while a build child keeps running, the group is force-killed and the job waits
for the group to empty. If even that is not confirmed, the job stays _cancelling_ and the interpreter stays
locked, because a second installation must not run while the first is still unaccounted for. The tab keeps
showing the job instead of reporting a completed cancellation.
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place. Each install
opens a new file and only the newest 20 are kept; the long-lived service log rotates to `service.log.1` once it
passes 5 MB.
- When the plugin unloads, running jobs are stopped through the same path: a graceful signal first, then a
force-kill, and the managed process group is watched until it is empty, so a pip that ignores `SIGTERM` — or a
build child that outlives pip — cannot outlive the Host.
## Workspace `.env`
ReMe reads credentials from the `.env` in the workspace directory at service start; the tab never stores them in
DSH settings.
| Group | Keys |
| --------- | -------------------------------------------------------------------------------------- |
| LLM | `LLM_BACKEND`, `LLM_MODEL_NAME`, `LLM_API_KEY`, `LLM_BASE_URL` |
| Embedding | `EMBEDDING_BACKEND`, `EMBEDDING_MODEL_NAME`, `EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL` |
- The workspace directory defaults to `~/.reme` and must be absolute; changing it reloads the `.env` it points at.
- API keys are write-only: the Host returns a mask (last four characters) and a blank field means “keep the
stored key”. Leaving a key untouched never rewrites it.
- Emptying a **secret** field and saving keeps the stored key, even when a replacement was typed first and then
cleared: a credential cannot be deleted by emptying its control, so the blank field always means “keep what is
stored”. Emptying a **non-secret** field and saving **clears** it. Only fields you edited are written, so the rest
of the file keeps its comments, unknown keys and order.
- The two backend selections are the non-secret fields with a default: clearing one writes the default
(`LLM_BACKEND=openai`, `EMBEDDING_BACKEND=openai`) instead of an empty value. ReMe applies
`${LLM_BACKEND:-openai}` and `${EMBEDDING_BACKEND:-openai}` only when the variable is absent, so an empty
value would name no backend at all and the service would refuse to start.
- A save writes the edits that were submitted and leaves newer ones alone: typing another field while the save is in
flight keeps that text, and the card says that unsaved edits are still pending.
- Saving validates the whole merged file, not just the values you typed: ReMe 0.4.0.x instantiates the embedding
component unconditionally, so `EMBEDDING_API_KEY` must be non-empty even without vector search (`EMPTY` is the
documented placeholder). A duplicated key is rewritten in every position, and the file keeps its own line
ending.
- The write is atomic and owner-only (0600 on POSIX); the previous file is kept as `.env.bak`, so a failed save
cannot lose unrelated keys.
- **Test LLM connection** runs a one-token request from inside the workspace with the selected interpreter, and
adds a hint for `401`/`403` (key), `404` (model or base URL) and network errors. The request follows the
protocol of the `LLM_BACKEND` in the `.env`: `openai`, `dashscope`, `deepseek` and `moonshot` use the
OpenAI-compatible `/chat/completions` (Bearer auth), `anthropic` uses `/v1/messages` (`x-api-key`), `gemini`
uses `/v1beta/models/<model>:generateContent` (`x-goog-api-key`), `ollama` uses `/api/chat` (a local server
needs no key) and `xai` uses the OpenAI-compatible `/chat/completions` at `api.x.ai`.
- The base URL follows ReMe's own credentials: `LLM_BASE_URL` is read for the OpenAI-compatible providers and for
Anthropic, while Gemini, Ollama and xAI carry their own endpoint and ignore it. A stale value left in the `.env`
therefore cannot send the probe to a host the service never calls, and a configuration ReMe accepts is never
reported as broken. The base URL is used as the provider's own SDK builds it, so a base that already carries the
version suffix fails in the probe exactly as it fails in the service.
- ReMe reaches xAI through its gRPC SDK, so the xAI probe is a REST-level approximation of that channel.
- The probe reads the `.env` on disk, so a configuration that is only picked in the form is not tested before the
file is saved; the button says so while it waits.
- A `.env` that sets `LLM_BACKEND` to an empty value is reported as an incomplete configuration
(`LLM config incomplete (LLM_BACKEND)`) and no request is sent: ReMe would start with no backend at all, so the
probe must not test a configuration the service cannot use. A `.env` without the key is probed with the default
backend, exactly as ReMe would start it.
- A known backend written in the wrong case (`LLM_BACKEND=OpenAI`) is reported as invalid, with the spelling to
use: ReMe's backend registry is case-sensitive and refuses that name. A backend name the plugin has never heard
of is still probed tolerantly — the list here is a snapshot, and ReMe may know more than the plugin does. That
holds however the name looks: only a backend the plugin actually knows can be reported as a wrong spelling.
- The draft, the save message and the LLM-test result belong to the workspace they were produced for: switching
the workspace disposes the section's state, so neither a key typed for one workspace nor a success reported for
it can be carried into another.
## Connection testing
**Test connection** in the hero asks the Host to `POST /health_check` on the configured endpoint. A healthy answer
must contain a ReMe health report — a version, a health flag and its components — so a generic HTTP server that
answers `200` with `{}` is not reported as a running ReMe service. The Host reuses the same ReMe client as the rest
of the plugin, so the request, the authentication and the three-second timeout are the ones the service itself is
called with. When the settings carry an `authToken`, the Host sends it as `Authorization: Bearer`, scoped to that
endpoint.
## Service lifetime
Starting the service here is equivalent to running `reme start` in a terminal — with one deliberate difference:
**the service outlives DSH**. Closing DSH does not stop it, and stopping it is an explicit action in the tab.
That is why the plugin does not hand the service to DSH's local subprocess provider, which terminates the
processes it manages when the Host exits. The plugin owns a small service adapter instead:
- The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time)
**after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind.
- A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on
screen, the pid is still alive, its image is still a Python or `reme` executable, **and** its command line still
carries the recorded `workspace_dir` and `service.port`. The command line rules out most recycled pids (a longer
workspace path or another port never matches), though it cannot tell a pid apart from a user who starts the exact
same command by hand. A record for port 2333 never claims a service on port 2444.
- When the evidence cannot be read (localized `tasklist`/`ps`, missing permissions), ownership stays unproven. Start
and Stop both leave the process and the record alone: Stop refuses, and Start reports an error instead of dropping
the record. Discarding it would orphan a live service that the tab could never stop again. The card then offers
**Forget this record**, which deletes only `service.json` and never signals the process; the user confirms what to do
with it themselves. The Host names the record that blocked the action, and **Forget** targets exactly that record —
which may belong to another port, not to the endpoint on screen — together with its pid. When the record changed in
the meantime, the Host refuses instead of dropping the new record silently.
- A service that occupies the port but fails the health check is reported as a conflict; if it is provably this
plugin's service, the tab still offers **Stop**.
- A conflict caused by a **recorded** process whose identity cannot be read is a different case: the process is
never signalled, so the tab offers **Forget this record** instead. The record travels with the status, so the way
out does not depend on a start that the conflict card disables.
- A record left by an older version under a path prefix of the same origin (for example
`127.0.0.1:2333/reme`) describes the **same** local service, because the launch configures only host
and port. When it can be proven to be this plugin's service, such a record is re-keyed to the endpoint on
screen and then managed like any other launch: **Stop** is offered, and **Start** replaces it while it is
not healthy. It is then not reported as a foreign process the tab cannot act on, which would leave the
user with a process to kill by hand.
- **Stop** tree-kills the recorded process (process group on POSIX, `taskkill /T /F` on Windows), waits for it to
exit, escalates (POSIX: `SIGTERM` then `SIGKILL`; Windows: force `taskkill` again), and clears the record only
after the pid and the POSIX process group are gone. A child that outlives its parent still receives the force-kill.
If the group cannot be confirmed empty, its record is retained; a later unverifiable group is never signalled again.
A service started elsewhere stays untouched and is only observed.
- A recorded launch that is still alive but never became healthy is shown as **Last launch not ready** with both
**Stop** and **Start**: Stop clears the record, while Start makes the Host stop a provably owned previous launch or
discard a proven-foreign one. It is not shown as "Starting" unless a start from this tab is actually in flight.
- **Start** is serialized: two requests cannot spawn two services, and a second request observes the launch
already in flight. Before replacing a recorded launch that never became healthy, Start only kills it when
ownership is proven; an unproven record is kept and reported, never silently dropped. The selected interpreter's
dependencies are checked before stopping the previous service.
Status reads before spawn acknowledgement keep the launch in flight. If a newer service record appears during
probing or spawning, it is preserved and any unrecorded new child is stopped before reporting failure.
Service-record read errors abort the operation instead of being treated as an absent record.
- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running.
A refused launch is reported in the DSH Host log.
Python probes and pip installs are bounded Host operations; the service is the only process that must outlive DSH.
## Platform support
| Area | Notes |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DSH | `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, `0.2.2-alpha.2` |
| Node.js | `^22.19.0` or `>=24.0.0`; current DSH releases need Node 24.9 or newer, and the compatibility matrix is validated with Node 24.16 |
| Windows | Tested on Windows 11 with Miniconda: registry/PATH/conda discovery, `tasklist`/`taskkill` for identity and stop, `PYTHONUTF8`/`PYTHONIOENCODING` for pip output |
| macOS / Linux | Discovery, detached spawn, identity lookup and stop are covered by unit tests that run a real child process; still less exercised on real machines than Windows |
## Troubleshooting
**No usable Python found.** Install Miniconda or any Python 3.11+, then press **Refresh**. Store placeholders
are filtered by design, so install a real distribution instead of the Store alias.
**The selected interpreter is not in the list.** The tab shows it with a “not detected” tag and still uses it.
Refresh, or pick a detected interpreter.
**Installation fails.** Open the log tail: missing wheels, a stale proxy or a restricted network usually show
there. Try a mirror. In a conda environment `pip` works, but a dedicated environment avoids dependency conflicts
with other projects.
**Saving `.env` is refused.** `LLM_API_KEY` and `EMBEDDING_API_KEY` must both be non-empty in the saved file;
use `EMPTY` for the embedding key when vector search is off. Emptying a secret field never reaches the file, so
the stored key is kept; a required key that is not stored at all still fails the save, and the file is left
unchanged. Only a missing file is treated as a first save; permission or I/O read errors abort the save without
replacing the active file or its backup. Names must start with a letter or underscore and contain only letters, digits and underscores; values must be single-line strings;
invalid updates leave both files unchanged.
**The service reports “Port occupied”.** Something else listens on the endpoint. Change the port in the service
URL (Memory settings) or stop the other program.
**The service does not become healthy.** The Start action polls for 120 seconds. If the port is answered but
`/health_check` never passes, the card switches to the conflict state so **Stop** stays available. Use **View log**
(the workspace `service.log`) for the startup trace — a missing `reme-ai`, an invalid `.env`, or a port conflict all
appear there.
**The service shows “Last launch not ready”.** A launch recorded by the plugin is still alive but never answered
`/health_check` (for example DSH closed while it was booting). Use **Stop** to clear it and start again. If the pid
was recycled by another program, that record is provably foreign: **Start** discards it and launches a fresh service.
If the identity cannot be verified at all, use **Forget this record** — it deletes only the plugin's record and
leaves the process untouched.
**The service shows as foreign.** It was started outside the plugin, so it is observed but not managed. Stop it
in its own terminal if you want the tab to manage the next launch.
**Stop is disabled.** The running service does not belong to this endpoint, or its record is gone. Restart it
from the tab to make it managed.
**LLM test fails.** The result distinguishes `401`/`403` (key), `404` (model name or base URL) and network
errors. The probe reads the workspace `.env`, so save before testing, and it follows the `LLM_BACKEND` there: a
native provider (Anthropic, Gemini, Ollama) is probed with its own protocol, not with an OpenAI-style request.
## Where the code lives
```text
integrations/dsh/src/environment/
├── types.ts shared, browser-safe RPC types
├── env-fields.ts the managed `.env` keys and the rules for updating them
├── dependency.ts what one interpreter can host
├── endpoint-rules.ts the endpoints a local service can be managed on
├── host/ detection, install, service adapter, manager, gateway
└── client/ the tab, one module per step, copy and styles
```
The Host modules are the only place that touches processes or files; the client renders what the Host returns.

View file

@ -0,0 +1,217 @@
# 环境管理
**Environment(环境管理)** 标签页用于在本机完成 ReMe 的准备工作:找到运行 DSH 那台机器上的 Python、把
`reme-ai[core]` 装进该解释器、写入 workspace 的 `.env`、并启动或停止本地 ReMe 服务——这些原本需要在多个终端里完成。
入口:**插件 → ReMe Bundle → reme-memory → 配置 → 环境管理**。
该标签页只管理**根路径上的回环地址**(`http://127.0.0.1:…` 或 `http://localhost:…`)。其他服务地址——远程主机,或挂在
路径前缀下的网关——只显示该地址是否可用,并隐藏本地步骤。
页面上的所有结果都来自 DSH **Host**,而不是浏览器:Python 探测、文件读写、LLM 探测、服务健康检查都在 Host 上执行。因此即使
浏览器在另一台机器上,看到的也是 Host 的真实环境,顶部概览与服务卡片不会给出不一致的结论。
## 每一步做什么
| 分组 | 能力 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 本地 Python | 探测 PEP 514 注册表、`py` 启动器、`PATH` 与 conda(base 与命名环境),标注来源和 `reme-ai` 状态,选择结果写入 `pythonPath` |
| ReMe 依赖 | 针对所选解释器检查 `reme-ai` 版本、`[core]` 附加依赖与 `reme` CLI;可选用软件源安装或升级 `reme-ai[core]`,显示实时日志并支持取消 |
| ReMe 参数 | 编辑 workspace 的 `.env`;密钥只存在该文件中,界面仅显示掩码。保存要求 `EMBEDDING_API_KEY` 非空(关闭向量检索时用 `EMPTY`)。提供 LLM 连通性测试 |
| 服务控制 | 启动/停止本地 ReMe 服务,显示 PID、版本与日志;启动时注入 UTF-8 与 `NO_PROXY`、设置 `enable_logo=false`,并在 workspace 内运行以保证 `.env` 能被发现 |
顶部的概览区用三个标签(服务、Python、ReMe)概括同一份状态,并显示服务地址、探测时间与两个刷新操作。
## Python 探测
ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能通过验证片段(上报 `sys.executable`、
版本号与 base prefix 的 `-c` 探测)的解释器:
- Windows:PEP 514 注册表项(`HKLM`/`HKCU`)、`py` 启动器列表、`PATH`、`conda env list`。
- macOS / Linux:`PATH`(`which -a`)、常见的 `/usr/bin`、`/usr/local`、Homebrew(`/opt/homebrew`)、
`~/.pyenv/shims` 与 `~/.local/bin` 路径,以及 `conda env list`。
`WindowsApps` 下的 Microsoft Store 占位符会被跳过,因为它们只是别名而不是解释器。装有 conda 时,命名环境会同时出现在
`conda env list` 与环境列表中,conda 根目录单独上报。
探测结果缓存 60 秒,点击概览区的**重新探测**会重新执行。推荐解释器按以下顺序选出:
1. 已安装受支持 `reme-ai` 的命名 conda 环境(排除 `base`)。
2. 其他已安装受支持 `reme-ai` 的解释器。
3. 任意命名 conda 环境(可直接安装 ReMe)。
4. 第一个可用解释器。
把鼠标悬停在**推荐**标签上可以看命中的是哪条规则。选择其它解释器只会写入 `pythonPath`;保持未选择则一直跟随推荐。列表默认
显示 6 行并可展开,支持按版本、来源、路径筛选。
## ReMe 依赖
检查会向所选解释器询问三件事,并以指标形式展示:
- `reme-ai` 元数据版本(`importlib.metadata.version("reme-ai")`),以及是否达到本集成要求的最低版本。
- `[core]` 附加依赖:`import agentscope` 是否可用。受支持的 ReMe 版本在加载 CLI 时就会导入 AgentScope,因此缺失时
无法启动服务;请安装或重装 `reme-ai[core]`。
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
依赖,视网络情况通常 5–20 分钟。
安装是 Host 侧的任务:
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。Host 按解释器查找正在运行的任务,所以两个解释器同时安装时,
各自看到的是自己的进度。
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示;安装的响应也绑定在发起它的解释器上,切换解释器后迟到的响应会被丢弃。
- 安装成功后会自动重新检测 `reme-ai`(同一个解释器),依赖卡片与**启动服务**按钮随之更新,无需手动点“检测”。
- **取消**会同时终止 pip 及其构建子进程,且在**整个受管进程组**真正退出前该解释器保持占用:pip 先退出而构建子进程
仍在时,会对进程组强制结束并等待组清空。若连这一步也无法确认,任务仍保持“正在取消”状态、该解释器依然被占用——
在第一个安装还没有交代清楚之前,不允许第二个安装开始。页面会继续显示该任务,而不是把它报告成一次已完成的取消。
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部。每次安装都会新建文件,目录只保留最近
20 个;长期运行的服务日志超过 5 MB 后轮转为 `service.log.1`。
- 插件卸载时,仍在运行的安装走同一条终止路径:先发优雅信号,再强制结束,并对受管进程组持续确认,因此忽略
`SIGTERM` 的 pip 或比 pip 活得更久的构建子进程都不会活得比 Host 更久。
## Workspace `.env`
ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把密钥写进 DSH 设置。
| 分组 | 键 |
| --------- | -------------------------------------------------------------------------------------- |
| LLM | `LLM_BACKEND`、`LLM_MODEL_NAME`、`LLM_API_KEY`、`LLM_BASE_URL` |
| Embedding | `EMBEDDING_BACKEND`、`EMBEDDING_MODEL_NAME`、`EMBEDDING_API_KEY`、`EMBEDDING_BASE_URL` |
- workspace 目录默认 `~/.reme`,必须是绝对路径;修改后会重新读取它指向的 `.env`。
- API Key 是只写的:Host 只返回掩码(末四位),留空表示保留已存的值。没有编辑过的键不会被重写。
- 清空**密钥**字段并保存会保留已存的密钥——即便先输入过替代值再清空也是如此:凭据无法通过清空输入框来删除,
因此留空始终表示“保留已存的值”。清空**非密钥**字段并保存即表示**删除**该值。只有编辑过的字段会被写入,
文件其余部分的注释、未知键与顺序保持不变。
- 两个后端选择是带默认值的非密钥字段:清空后会写入默认值(`LLM_BACKEND=openai`、`EMBEDDING_BACKEND=openai`),
而不是空值。ReMe 只在变量**不存在**时才应用 `${LLM_BACKEND:-openai}` 与 `${EMBEDDING_BACKEND:-openai}`,
空值等于没有指定任何后端,服务将无法启动。
- 保存只清除本次提交的那部分编辑,更晚的编辑会被保留:保存进行中再改动其它字段时,那份文本不会丢失,页面也会
提示仍有未保存的改动。
- 保存时会校验合并后的整份文件,而不只是本次输入:ReMe 0.4.0.x 会无条件实例化 embedding 组件,因此即使不使用向量检索
`EMBEDDING_API_KEY` 也必须非空(`EMPTY` 是文档化的占位值)。重复出现的键会在每一处被改写,文件原有的换行风格也会保留。
- 写入是原子且仅属主可读写的(POSIX 上为 0600);旧文件会保留为 `.env.bak`,因此一次失败的保存不会丢掉其它键。
- **测试 LLM 连接**在 workspace 内、用所选解释器发送一次只需 1 个 token 的请求,并对 `401`/`403`
(密钥)、`404`(模型名或 Base URL)与网络错误给出提示。请求按 `.env` 里的 `LLM_BACKEND` 选择协议:
`openai`、`dashscope`、`deepseek`、`moonshot` 走 OpenAI 兼容的 `/chat/completions`(Bearer 认证),
`anthropic` 走 `/v1/messages`(`x-api-key`),`gemini` 走 `/v1beta/models/<model>:generateContent`
(`x-goog-api-key`),`ollama` 走 `/api/chat`(本地服务无需密钥),`xai` 走 `api.x.ai` 上 OpenAI 兼容的
`/chat/completions`。
- Base URL 的来源与 ReMe 自身的凭据保持一致:`LLM_BASE_URL` 只对 OpenAI 兼容族与 Anthropic 生效,Gemini、Ollama
与 xAI 自带地址、并不读取该变量。因此 `.env` 里残留的旧值不会把探测引到服务根本不会访问的主机,ReMe 能接受的
配置也不会被报成故障。Base URL 的拼接方式与服务商 SDK 完全一致,因此已经带版本后缀的地址在探测与服务中都会
同样失败。
- ReMe 通过 gRPC SDK 连接 xAI,因此 xAI 的探测只是该通道在 REST 层面的近似。
- 探测读取的是磁盘上的 `.env`,所以只在表单里改过配置而未保存时不会发起测试;等待期间按钮会给出提示。
- 若 `.env` 把 `LLM_BACKEND` 设为空值,探测会报告配置不完整(`LLM config incomplete (LLM_BACKEND)`)并且不发送
任何请求:此时 ReMe 启动时并没有任何后端,探测不能去测试服务根本无法使用的配置。若 `.env` 里没有这个键,
探测会与 ReMe 一样使用默认后端。
- 已知后端名写错了大小写(如 `LLM_BACKEND=OpenAI`)会被报告为无效,并给出正确写法:ReMe 的后端注册表区分大小写,
会拒绝这个名字。而插件从未听说过的后端名仍被宽容地探测——这里的列表只是一份快照,ReMe 可能认识得更多。这条规则与名字
长什么样无关:只有插件确实认识的后端才会被判定为大小写写错。
- 草稿、保存提示与 LLM 测试结果都属于产生它们的 workspace:切换 workspace 会随旧区块一并丢弃,因此为某个 workspace
输入的密钥、或属于它的一次成功提示,都不会被带到另一个。
## 连接测试
概览区的**测试连接**让 Host 对配置的服务地址执行 `POST /health_check`。健康响应必须包含可解析的 ReMe 健康报告——版本、
健康标记与各组件明细——所以只回 `200` 与 `{}` 的普通 HTTP 服务不会被当成运行中的 ReMe。Host 复用插件其余部分使用的
同一个 ReMe 客户端,因此请求、鉴权与三秒超时都与服务本身被调用时一致。设置里填了 `authToken` 时,Host 会以
`Authorization: Bearer` 发送,且只作用于该服务地址。
## 服务生命周期
在这里启动服务等价于在终端执行 `reme start`,但有一点是刻意的差异:**服务会比 DSH 活得更久**。关闭 DSH 不会停止它,停止
它是在标签页里的一次显式操作。
因此本插件没有把服务交给 DSH 的本地子进程提供方——后者会在 Host 退出时终止它托管的进程。插件自带一个小型服务适配器:
- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间),
所以解释器缺失时不会留下残留状态。
- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与
`service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行能排除大多数 pid 复用(更长的
workspace 路径、另一个端口都不会命中),但无法区分“用户照抄同一命令行手工启动”的同一服务。2333 端口上的记录绝不会
声称管理 2444 端口上的服务。
- 证据读不到时(`tasklist`/`ps` 输出本地化、权限不足等)归属保持“未证明”:启动与停止都不会碰进程、也不会丢弃记录——
停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;此时卡片会给出**忘记这条记录**,
它只删除 `service.json`、绝不向进程发信号,由用户自行确认后续处理。Host 会在结果里指明挡住操作的是哪一条记录,
**忘记**针对的正是那条记录(可能是另一个端口上的记录,而不是当前显示的地址),并带上它的 pid:记录在此期间被换掉时,
Host 会拒绝而不是静默丢弃新记录。
- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。
- 占用端口的是**已记录**但身份无法验证的进程时情况不同:该进程绝不会被发信号,因此标签页改为给出**忘记这条记录**。
这条记录随状态一起返回,所以出路不依赖被“端口被占用”状态禁用的**启动**按钮。
- 旧版本遗留在同一源(origin)某个路径前缀下(例如 `127.0.0.1:2333/reme`)的记录,描述的是**同一个**本地服务——
启动只配置 host 与端口。只要能证明它是本插件启动的,这种记录就会被重新登记到当前显示的地址,随后与其他启动
一样被管理:给出**停止**按钮,**启动**在它尚未健康时替换它。它不会再被当成标签页无从处理的外部进程,否则用户
只能自己去杀进程。
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX:
`SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 及 POSIX 进程组真正消失之后**才清除记录。
父进程先退出时,仍会强制结束存活的子进程。无法确认进程组已清空就保留记录;后续无法验证身份的进程组不会再次被发信号。
别处启动的服务不受影响,只会被观察。
- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并同时给出**停止**与**启动**:停止负责清记录,启动则让 Host
停掉可证明归属的旧启动、或丢弃已证明属于外部的记录。只有本标签页发起的启动仍在进行时才会显示“启动中”。
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。停止旧服务前会先检查所选解释器的启动依赖。
在操作系统确认启动之前,状态查询不会清除启动标记。探测或启动过程中出现更新的服务记录时,会保留新记录,
并在报告失败前停止尚未登记的新子进程;读取记录失败会中止操作,不会按“没有记录”继续启动。
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起;被拒绝的启动会记入 DSH Host 日志。
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。
## 平台支持
| 方面 | 说明 |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| DSH | `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2`、`0.2.2-alpha.2` |
| Node.js | `^22.19.0` 或 `>=24.0.0`;当前 DSH 需要 Node 24.9 以上,兼容矩阵以 Node 24.16 验证 |
| Windows | 在 Windows 11 + Miniconda 上实测:注册表/PATH/conda 探测、`tasklist`/`taskkill` 身份校验与停止、`PYTHONUTF8`/`PYTHONIOENCODING` 保证 pip 输出不乱码 |
| macOS/Linux | 探测、分离启动、身份查询与停止均有运行真实子进程的单元测试覆盖;真机验证程度仍低于 Windows |
## 常见问题
**未检测到可用的 Python。** 安装 Miniconda 或任意 Python 3.11+,再点**重新探测**。Store 占位符会被过滤,请安装真实的发行版
而不是 Store 别名。
**所选解释器不在列表里。** 标签页会用“不在探测结果中”标注它,但仍然使用它。重新探测,或改选探测到的解释器。
**保存 `.env` 时读取失败。** 只有文件不存在才按首次保存处理;权限或 I/O 错误会中止保存,不会替换原文件或备份。变量名须以字母或下划线开头,其余只接受字母、数字和下划线;值必须是单行字符串;无效更新不会改动原文件或备份。
**安装失败。** 先看日志尾部:缺少 wheel、代理配置过期或网络受限通常都能看出来,可以换一个软件源。conda 环境里 `pip` 也可
用,但独立环境更能避免与其它项目的依赖冲突。
**保存 `.env` 被拒绝。** 保存后的文件中 `LLM_API_KEY` 与 `EMBEDDING_API_KEY` 都必须非空;关闭向量检索时 embedding 用
`EMPTY`。清空密钥字段不会写入文件,已存的密钥会被保留;而一个根本没有存过的必需键仍会让保存失败,文件保持原样。
**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。
**服务迟迟未就绪。** 启动会轮询 120 秒;如果端口有响应但 `/health_check` 一直不通过,卡片会切到“端口被占用”状态,**停止**按钮
始终可用。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、`.env` 不合法、端口冲突都会体现在那里。
**服务显示“上次启动未就绪”。** 本插件记录的进程仍存活,但一直没有通过 `/health_check`(例如启动途中 DSH 被关闭)。点**停止**
清掉它后重新启动。若 pid 已被其它程序复用,这条记录可证明属于外部:点**启动**会丢弃记录并拉起新服务。若完全无法验证身份,
点**忘记这条记录**——它只删除本插件的记录,不会碰进程。
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从
这里启动。
**停止按钮不可用。** 运行中的服务不属于当前服务地址,或记录已丢失。从标签页重新启动一次即可纳入管理。
**LLM 测试失败。** 结果会区分 `401`/`403`(密钥)、`404`(模型名或 Base URL)与网络错误。探测读取的是 workspace 的
`.env`,所以请先保存再测试;探测协议也取自其中的 `LLM_BACKEND`:原生后端(Anthropic、Gemini、Ollama)用自己的协议
探测,不会被当成 OpenAI 兼容接口。
## 代码位置
```text
integrations/dsh/src/environment/
├── types.ts 浏览器安全的共享 RPC 类型
├── env-fields.ts 受管的 `.env` 键及其更新规则
├── dependency.ts 一个解释器能承载什么
├── endpoint-rules.ts 哪些服务地址可以作为本地服务管理
├── host/ 探测、安装、服务适配器、manager、gateway
└── client/ 标签页:每个步骤一个模块,以及文案与样式
```
只有 Host 模块会接触进程与文件;客户端只负责渲染 Host 返回的结果。

View file

@ -20,6 +20,7 @@
"cordis.patch.yml",
"README.md",
"README_ZH.md",
"docs",
"figures"
],
"dsh": {
@ -51,12 +52,12 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1"
"@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1 || ~4.0.6-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2",
"@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1 || ~3.18.6-alpha.1"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {

View file

@ -38,24 +38,62 @@ const releases = [
schema: "3.18.5-alpha.2",
},
{ dsh: "0.2.1-rc.2", cordis: "4.0.5", loader: "1.0.6", schema: "3.18.5" },
{
dsh: "0.2.2-alpha.2",
cordis: "4.0.6-alpha.1",
loader: "1.0.7-alpha.1",
schema: "3.18.6-alpha.1",
},
];
async function run(command, args, cwd) {
// `npm` is `npm.cmd` on Windows, which execFile cannot launch directly; the
// script runs the npm CLI through node instead, using the path npm exported to
// this process.
const npmCommand = process.env.npm_execpath
? [process.execPath, [process.env.npm_execpath]]
: ["npm", []];
async function run(command, args, cwd, label = "command") {
try {
return await execFileAsync(command, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
} catch (error) {
process.stderr.write(error.stdout || "");
process.stderr.write(error.stderr || "");
throw error;
const stdout = String(error.stdout ?? "");
const stderr = String(error.stderr ?? "");
const combined = `${stdout}\n${stderr}`;
// The raw TAP buffer is far too large for a CI log and hides which step
// failed. Report the failing cases and a short tail instead.
const failures = combined
.split("\n")
.map((line) => line.trim())
.filter((line) => /^not ok \d+ - /.test(line) || line.startsWith("✖ "))
.slice(0, 20);
const tail = combined.trim().split("\n").slice(-40).join("\n");
process.stderr.write(
`\n[test-package] ${label} failed (exit ${error.code ?? "error"})\n`,
);
if (failures.length > 0)
process.stderr.write(
`[test-package] failing tests:\n${failures.join("\n")}\n`,
);
if (tail) process.stderr.write(`[test-package] last output:\n${tail}\n`);
// A short error keeps Node's unhandled-rejection dump readable; the large
// stdout/stderr buffers stay out of it.
const failure = new Error(`${label} failed: ${error.message}`);
failure.code = error.code;
throw failure;
}
}
/** Run the npm CLI on the platform this check runs on. */
function runNpm(args, cwd, label = "npm") {
return run(npmCommand[0], [...npmCommand[1], ...args], cwd, label);
}
try {
const { stdout } = await run(
"npm",
const { stdout } = await runNpm(
[
"pack",
"--json",
@ -64,6 +102,7 @@ try {
temporaryDirectory,
],
root,
"npm pack",
);
const [result] = JSON.parse(stdout);
const files = new Set(result.files.map(({ path: file }) => file));
@ -72,6 +111,7 @@ try {
"dist/client.js",
"cordis.patch.yml",
"README.md",
"docs/environment.md",
"figures/reme-status-overview.png",
]) {
assert.ok(files.has(file), `missing ${file}`);
@ -112,8 +152,7 @@ try {
JSON.stringify({ private: true, type: "module", dependencies }),
);
// Install as a consumer with normal peer resolution, then check the shipped artifact.
await run(
"npm",
await runNpm(
[
"install",
"--ignore-scripts",
@ -123,6 +162,7 @@ try {
"--fetch-timeout=30000",
],
consumer,
`install DSH ${release.dsh}`,
);
await run(
process.execPath,
@ -136,6 +176,7 @@ try {
`,
],
consumer,
`import DSH ${release.dsh}`,
);
const installed = path.join(consumer, "node_modules", manifest.name);
@ -154,10 +195,11 @@ try {
.map((file) => path.join("tests", file));
const tested = await run(
process.execPath,
["--test", ...testFiles],
["--test", "--test-reporter=tap", ...testFiles],
installed,
`tests DSH ${release.dsh}`,
);
await run("npm", ["run", "build"], installed);
await runNpm(["run", "build"], installed, `build DSH ${release.dsh}`);
const count = /# tests (\d+)/.exec(tested.stdout)?.[1];
process.stdout.write(
`DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`,

View file

@ -1,8 +1,19 @@
import { useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives";
import type {
SettingsFieldState,
SettingsFormShell,
} from "@deepseek-ai/dsh-client-ui-primitives";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { ReMeSettings } from "../types.js";
import { validEndpoint } from "../endpoint.js";
import {
ReMeEnvironmentTab,
envEn,
envZh,
type EnvTranslator,
} from "../environment/client/tab.js";
import { environmentStyles } from "../environment/client/styles.js";
import {
ReMeStatusPage,
statusEn,
@ -10,38 +21,23 @@ import {
type StatusRpc,
type StatusTranslator,
} from "./status-page.js";
import { SETTINGS_SPECS } from "./settings-specs.js";
import { styles } from "./styles.js";
const NS = "reme.settings";
const STATUS_NS = "reme.status";
const ENV_NS = "reme.environment";
const SETTINGS_NS = "reme-memory";
const FIELDS = [
"endpoint",
"requestTimeoutMs",
"backgroundTimeoutMs",
"shutdownTimeoutMs",
"autoMemoryRetryMs",
"autoMemoryRetryMaxMs",
"autoMemoryEnabled",
"autoMemoryInterval",
"autoDreamEnabled",
"dreamCron",
"dreamHint",
"rootAgentsOnly",
"language",
"searchLimit",
"timezone",
] as const satisfies readonly (keyof ReMeSettings)[];
type Field = (typeof FIELDS)[number];
type Translator = (key: keyof typeof en) => string;
interface ClientContext {
get(name: string): { rpc: StatusRpc };
effect(factory: () => (() => void) | void, label: string): void;
effect(factory: () => void | (() => void), label: string): void;
locale: {
bind(namespace: typeof NS): Translator;
bind(namespace: typeof STATUS_NS): StatusTranslator;
bind(namespace: typeof ENV_NS): EnvTranslator;
register(
namespace: string,
dictionaries: {
@ -63,27 +59,61 @@ interface ClientContext {
};
}
interface Draft {
endpoint: string;
requestTimeoutMs: string;
backgroundTimeoutMs: string;
shutdownTimeoutMs: string;
autoMemoryRetryMs: string;
autoMemoryRetryMaxMs: string;
autoMemoryEnabled: boolean;
autoMemoryInterval: string;
autoDreamEnabled: boolean;
dreamTime: string;
dreamHint: string;
rootAgentsOnly: boolean;
language: "en" | "zh";
searchLimit: string;
timezone: string;
/** One control as the model currently stages it. */
type FieldState = SettingsFieldState;
/**
* The slice of DSH's snapshot store this card reads.
*
* `@deepseek-ai/dsh-client-store` is not a dependency of this package, so the
* store's own type is not available here; the card declares the two members it
* uses instead of depending on the package.
*/
interface BoundStore<T> {
getSnapshot(): T;
subscribe(listener: () => void): () => void;
}
/** Everything the card renders, rebuilt from the model on every publish. */
interface CardState {
/** Whether the Host still serves this namespace to this client. */
status: "loading" | "ready" | "unavailable";
/** What a save would do, and whether one is crossing the wire. */
shell: SettingsFormShell;
/** Every control's staged state, keyed by field name. */
fields: Record<string, FieldState>;
}
/**
* Rebuild the card's view state from the model.
*
* The model publishes when the scope changes, when a draft is staged, when a
* save starts and when it settles, so this projection is the card's only
* source of truth: what it renders is what the model holds.
*
* The projection carries the model's own state and nothing the settings
* service owns: the store freezes what it is given, and the resolved section
* belongs to the service, which replaces it rather than editing it.
*/
function projectCard(
model: SettingsFormModel<ReMeSettings>,
scope: ConfigForm<ReMeSettings>,
): CardState {
const fields: Record<string, FieldState> = {};
for (const spec of SETTINGS_SPECS)
fields[spec.field] = model.field(spec.field);
return {
status: scope.getSnapshot().status,
shell: model.shell(),
fields,
};
}
interface ReMeCardProps {
scope: ConfigForm<ReMeSettings>;
t: Translator;
rpc?: StatusRpc;
envT?: EnvTranslator;
view?: "summary" | "page";
}
@ -121,6 +151,8 @@ const en = {
retryDelay: "Retry delay (ms)",
retryMaxDelay: "Maximum retry delay (ms)",
diagnostics: "Service diagnostics",
tabMemory: "Memory settings",
tabEnvironment: "Environment",
processMemory: "Process memory",
componentMemory: "Component memory",
components: "Health components",
@ -196,6 +228,8 @@ const zh: typeof en = {
retryDelay: "失败重试间隔(毫秒)",
retryMaxDelay: "最大重试间隔(毫秒)",
diagnostics: "服务诊断",
tabMemory: "记忆设置",
tabEnvironment: "环境管理",
processMemory: "进程内存",
componentMemory: "组件内存",
components: "健康组件",
@ -250,7 +284,12 @@ export function apply(ctx: ClientContext): void {
() => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }),
"remeMemory.statusLocale()",
);
ctx.effect(
() => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }),
"remeMemory.envLocale()",
);
ctx.effect(() => installStyles(), "remeMemory.settingsStyles()");
const envT = ctx.locale.bind(ENV_NS);
const scope = ctx.configForms.get<ReMeSettings>(SETTINGS_NS);
ctx.effect(
() =>
@ -262,7 +301,12 @@ export function apply(ctx: ClientContext): void {
key: "@agentscope-ai/reme-dsh-plugin#reme-memory",
label: () => t("title"),
locale: NS,
inject: () => ({ scope, t }),
inject: () => ({
scope,
t,
rpc: ctx.get("connection").rpc,
envT,
}),
},
ReMeSettingsCard,
),
@ -291,177 +335,217 @@ export function apply(ctx: ClientContext): void {
function ReMeSettingsCard({
scope,
t,
rpc,
envT,
view,
}: ReMeCardProps): JSX.Element | null {
const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
// DSH's form model stages the drafts, marks the ones that are invalid and
// writes every staged edit as one revision-fenced mutation, so this page owns
// only its controls and the field specs behind them.
//
// The model, its actions and the store it publishes to are built once: the
// model owns one subscription to the scope, and `bind` registers one
// listener on the model, so a second build would publish every change twice.
const [form] = useState(() => {
const model = new SettingsFormModel<ReMeSettings>(scope, SETTINGS_SPECS);
return {
model,
actions: model.actions(),
store: model.bind(() =>
projectCard(model, scope),
) as BoundStore<CardState>,
};
});
// The model publishes on a scope change, on every staged edit, when a save
// starts and when it settles. The card reads those publications and nothing
// else, so a staged edit repaints the controls on its own.
const state = useSyncExternalStore(
form.store.subscribe,
form.store.getSnapshot,
);
const [draftOverride, setDraft] = useState<{
value: Draft;
revision: number | undefined;
}>();
const [resetAll, setResetAll] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
const value = snapshot.value;
const draft =
draftOverride?.value ??
(value === undefined ? undefined : draftFrom(value));
const disposeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [saved, setSaved] = useState(false);
const [tab, setTab] = useState<"memory" | "environment">("memory");
useEffect(() => {
// React StrictMode mounts, cleans up and mounts again in development.
// Disposing the model immediately would hand the remounted card a dead
// one, so the cleanup is deferred and cancelled by the next mount.
if (disposeTimer.current !== undefined) {
clearTimeout(disposeTimer.current);
disposeTimer.current = undefined;
}
return () => {
disposeTimer.current = setTimeout(() => {
disposeTimer.current = undefined;
form.model.dispose();
}, 0);
};
}, [form.model]);
if (view === "summary") return <>{t("description")}</>;
if (snapshot.status === "unavailable") return null;
const dirty =
resetAll ||
(value !== undefined &&
draft !== undefined &&
!sameDraft(draft, draftFrom(value)));
const validation = draft === undefined ? undefined : parseDraft(draft);
if (state.status === "unavailable") return null;
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
if (value === undefined) return;
setDraft((current) => ({
value: { ...(current?.value ?? draftFrom(value)), [field]: next },
revision: current ? current.revision : snapshot.revision,
}));
setResetAll(false);
setSaveState("");
const { shell, fields } = state;
// The model's own saving state: it is true from the moment the save starts
// until the write settles, so the card keeps no second flag for it.
const saving = shell.saving;
// "The maximum retry delay is at least the initial one" is a relation between
// two fields, which a per-field spec cannot state, so the page keeps it.
const retryMax = (fields["autoMemoryRetryMaxMs"]?.text ?? "").trim();
const retryBase = (fields["autoMemoryRetryMs"]?.text ?? "").trim();
const inconsistent =
retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase);
const blocked = shell.invalid || inconsistent;
const edit = (name: string, text: string): void => {
setSaved(false);
form.actions.edit(name, text);
};
const save = async () => {
if (
value === undefined ||
draft === undefined ||
validation === undefined ||
!snapshot.writable ||
!dirty ||
saving
)
const save = async (): Promise<void> => {
const current = form.store.getSnapshot().shell;
if (!current.writable || !current.dirty || blocked || current.saving)
return;
setSaving(true);
setSaveState("");
try {
const ops = resetAll
? FIELDS.map((field) => ({ op: "unset" as const, path: [field] }))
: FIELDS.flatMap((field) =>
Object.is(value[field], validation[field])
? []
: [
{
op: "set" as const,
path: [field],
value: validation[field],
},
],
);
const success = await scope.mutate(
ops,
draftOverride?.revision ?? snapshot.revision,
);
setSaveState(success ? "saved" : "failed");
if (success) {
setDraft(undefined);
setResetAll(false);
}
} catch {
setSaveState("failed");
} finally {
setSaving(false);
}
setSaved(false);
await form.model.save();
// The model published the settled state before its promise resolved, so the
// store already carries the outcome of the write.
const after = form.store.getSnapshot().shell;
setSaved(!after.failed && !after.dirty);
};
const reset = (): void => {
setSaved(false);
for (const spec of SETTINGS_SPECS) form.actions.resetField(spec.field);
};
const discard = (): void => {
setSaved(false);
form.actions.discard();
};
return (
<div className="reme-settings-card">
<div className="reme-settings-body">
{dirty ? <p className="reme-settings-pending">{t("unsaved")}</p> : null}
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
<fieldset
className="reme-settings-fields"
disabled={saving || !snapshot.writable}
>
<SettingsForm draft={draft} update={update} t={t} />
</fieldset>
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft({
value: draftFrom(baseSettings(snapshot.base, value)),
revision: snapshot.revision,
});
setResetAll(true);
setSaveState("");
}}
<div className="reme-settings-tabs" role="tablist">
<button
type="button"
role="tab"
id="reme-tab-memory"
aria-controls={
tab === "memory" ? "reme-tabpanel-memory" : undefined
}
aria-selected={tab === "memory"}
className={tab === "memory" ? "active" : ""}
onClick={() => setTab("memory")}
>
{t("tabMemory")}
</button>
<button
type="button"
role="tab"
id="reme-tab-environment"
aria-controls={
tab === "environment" ? "reme-tabpanel-environment" : undefined
}
aria-selected={tab === "environment"}
className={tab === "environment" ? "active" : ""}
onClick={() => setTab("environment")}
>
{t("tabEnvironment")}
</button>
</div>
<div
role="tabpanel"
id={`reme-tabpanel-${tab}`}
aria-labelledby={`reme-tab-${tab}`}
>
{tab === "environment" ? (
rpc && envT ? (
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
) : (
<p className="reme-settings-muted">{t("loading")}</p>
)
) : state.status === "ready" ? (
<>
{shell.dirty ? (
<p className="reme-settings-pending">{t("unsaved")}</p>
) : null}
<fieldset
className="reme-settings-fields"
disabled={saving || !shell.writable}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">{t("saveFailed")}</span>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(undefined);
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
)}
<SettingsForm fields={fields} edit={edit} t={t} />
</fieldset>
{!shell.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{blocked ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!shell.writable || saving}
onClick={reset}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{shell.failed ? (
<span className="reme-settings-error">
{t("saveFailed")}
</span>
) : null}
{saved && !shell.dirty ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!shell.dirty || saving}
onClick={discard}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!shell.writable || !shell.dirty || blocked || saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
) : (
<p className="reme-settings-muted">{t("loading")}</p>
)}
</div>
</div>
</div>
);
}
/** The memory-settings controls, all reading and writing through the model. */
function SettingsForm({
draft,
update,
fields,
edit,
t,
}: {
draft: Draft;
update: <K extends keyof Draft>(field: K, value: Draft[K]) => void;
fields: Record<string, FieldState>;
edit: (name: string, text: string) => void;
t: Translator;
}): JSX.Element {
const text = (name: string): string => fields[name]?.text ?? "";
const invalid = (name: string): boolean => fields[name]?.invalid ?? false;
const checked = (name: string): boolean => text(name) === "true";
return (
<>
<section className="reme-settings-section">
@ -469,17 +553,15 @@ function SettingsForm({
<div className="reme-settings-grid">
<Field wide label={t("endpoint")} hint={t("endpointHint")}>
<input
value={draft.endpoint}
aria-invalid={!validEndpoint(draft.endpoint)}
onChange={(event) => update("endpoint", event.target.value)}
value={text("endpoint")}
aria-invalid={invalid("endpoint")}
onChange={(event) => edit("endpoint", event.target.value)}
/>
</Field>
<Field label={t("language")}>
<select
value={draft.language}
onChange={(event) =>
update("language", event.target.value as "en" | "zh")
}
value={text("language")}
onChange={(event) => edit("language", event.target.value)}
>
<option value="zh">中文</option>
<option value="en">English</option>
@ -490,8 +572,8 @@ function SettingsForm({
type="number"
min="1"
max="50"
value={draft.searchLimit}
onChange={(event) => update("searchLimit", event.target.value)}
value={text("searchLimit")}
onChange={(event) => edit("searchLimit", event.target.value)}
/>
</Field>
<Field label={t("requestTimeout")}>
@ -499,10 +581,8 @@ function SettingsForm({
type="number"
min="1000"
max="120000"
value={draft.requestTimeoutMs}
onChange={(event) =>
update("requestTimeoutMs", event.target.value)
}
value={text("requestTimeoutMs")}
onChange={(event) => edit("requestTimeoutMs", event.target.value)}
/>
</Field>
</div>
@ -512,30 +592,32 @@ function SettingsForm({
<div className="reme-settings-grid">
<Check
label={t("autoMemoryEnabled")}
checked={draft.autoMemoryEnabled}
onChange={(value) => update("autoMemoryEnabled", value)}
checked={checked("autoMemoryEnabled")}
onChange={(next) =>
edit("autoMemoryEnabled", next ? "true" : "false")
}
/>
<Check
label={t("rootOnly")}
checked={draft.rootAgentsOnly}
onChange={(value) => update("rootAgentsOnly", value)}
checked={checked("rootAgentsOnly")}
onChange={(next) => edit("rootAgentsOnly", next ? "true" : "false")}
/>
<Field label={t("autoMemoryInterval")}>
<input
type="number"
min="1"
max="1000"
value={draft.autoMemoryInterval}
value={text("autoMemoryInterval")}
onChange={(event) =>
update("autoMemoryInterval", event.target.value)
edit("autoMemoryInterval", event.target.value)
}
/>
</Field>
<Field label={t("timezone")}>
<input
value={draft.timezone}
aria-invalid={!validTimezone(draft.timezone)}
onChange={(event) => update("timezone", event.target.value)}
value={text("timezone")}
aria-invalid={invalid("timezone")}
onChange={(event) => edit("timezone", event.target.value)}
/>
</Field>
</div>
@ -545,20 +627,22 @@ function SettingsForm({
<div className="reme-settings-grid">
<Check
label={t("autoDreamEnabled")}
checked={draft.autoDreamEnabled}
onChange={(value) => update("autoDreamEnabled", value)}
checked={checked("autoDreamEnabled")}
onChange={(next) =>
edit("autoDreamEnabled", next ? "true" : "false")
}
/>
<Field label={t("dreamTime")}>
<input
type="time"
value={draft.dreamTime}
onChange={(event) => update("dreamTime", event.target.value)}
value={text("dreamCron")}
onChange={(event) => edit("dreamCron", event.target.value)}
/>
</Field>
<Field wide label={t("dreamHint")}>
<textarea
value={draft.dreamHint}
onChange={(event) => update("dreamHint", event.target.value)}
value={text("dreamHint")}
onChange={(event) => edit("dreamHint", event.target.value)}
/>
</Field>
</div>
@ -571,31 +655,31 @@ function SettingsForm({
type="number"
min="1000"
max="3600000"
value={draft.backgroundTimeoutMs}
value={text("backgroundTimeoutMs")}
onChange={(event) =>
update("backgroundTimeoutMs", event.target.value)
edit("backgroundTimeoutMs", event.target.value)
}
/>
</Field>
<Field label={t("retryDelay")}>
<input
type="number"
min={1000}
max={60000}
value={draft.autoMemoryRetryMs}
min="1000"
max="60000"
value={text("autoMemoryRetryMs")}
onChange={(event) =>
update("autoMemoryRetryMs", event.target.value)
edit("autoMemoryRetryMs", event.target.value)
}
/>
</Field>
<Field label={t("retryMaxDelay")}>
<input
type="number"
min={1000}
max={3600000}
value={draft.autoMemoryRetryMaxMs}
min="1000"
max="3600000"
value={text("autoMemoryRetryMaxMs")}
onChange={(event) =>
update("autoMemoryRetryMaxMs", event.target.value)
edit("autoMemoryRetryMaxMs", event.target.value)
}
/>
</Field>
@ -604,9 +688,9 @@ function SettingsForm({
type="number"
min="100"
max="60000"
value={draft.shutdownTimeoutMs}
value={text("shutdownTimeoutMs")}
onChange={(event) =>
update("shutdownTimeoutMs", event.target.value)
edit("shutdownTimeoutMs", event.target.value)
}
/>
</Field>
@ -657,130 +741,10 @@ function Check({
);
}
function draftFrom(value: ReMeSettings): Draft {
return {
endpoint: value.endpoint,
requestTimeoutMs: String(value.requestTimeoutMs),
backgroundTimeoutMs: String(value.backgroundTimeoutMs),
shutdownTimeoutMs: String(value.shutdownTimeoutMs),
autoMemoryRetryMs: String(value.autoMemoryRetryMs),
autoMemoryRetryMaxMs: String(value.autoMemoryRetryMaxMs),
autoMemoryEnabled: value.autoMemoryEnabled,
autoMemoryInterval: String(value.autoMemoryInterval),
autoDreamEnabled: value.autoDreamEnabled,
dreamTime: timeFromCron(value.dreamCron),
dreamHint: value.dreamHint,
rootAgentsOnly: value.rootAgentsOnly,
language: value.language,
searchLimit: String(value.searchLimit),
timezone: value.timezone,
};
}
function parseDraft(draft: Draft): ReMeSettings | undefined {
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
const backgroundTimeoutMs = boundedInteger(
draft.backgroundTimeoutMs,
1000,
3600000,
);
const shutdownTimeoutMs = boundedInteger(draft.shutdownTimeoutMs, 100, 60000);
const autoMemoryRetryMs = boundedInteger(
draft.autoMemoryRetryMs,
1000,
60000,
);
const autoMemoryRetryMaxMs = boundedInteger(
draft.autoMemoryRetryMaxMs,
autoMemoryRetryMs ?? 1000,
3600000,
);
const autoMemoryInterval = boundedInteger(draft.autoMemoryInterval, 1, 1000);
const searchLimit = boundedInteger(draft.searchLimit, 1, 50);
if (
!validEndpoint(draft.endpoint) ||
!validTimezone(draft.timezone) ||
!/^\d{2}:\d{2}$/.test(draft.dreamTime)
)
return undefined;
if (
[
requestTimeoutMs,
backgroundTimeoutMs,
shutdownTimeoutMs,
autoMemoryRetryMs,
autoMemoryRetryMaxMs,
autoMemoryInterval,
searchLimit,
].some((value) => value === undefined)
)
return undefined;
const [hour, minute] = draft.dreamTime.split(":");
return {
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
requestTimeoutMs: requestTimeoutMs!,
backgroundTimeoutMs: backgroundTimeoutMs!,
shutdownTimeoutMs: shutdownTimeoutMs!,
autoMemoryRetryMs: autoMemoryRetryMs!,
autoMemoryRetryMaxMs: autoMemoryRetryMaxMs!,
autoMemoryEnabled: draft.autoMemoryEnabled,
autoMemoryInterval: autoMemoryInterval!,
autoDreamEnabled: draft.autoDreamEnabled,
dreamCron: `${Number(minute)} ${Number(hour)} * * *`,
dreamHint: draft.dreamHint,
rootAgentsOnly: draft.rootAgentsOnly,
language: draft.language,
searchLimit: searchLimit!,
timezone: draft.timezone.trim(),
};
}
function boundedInteger(
text: string,
minimum: number,
maximum: number,
): number | undefined {
const value = Number(text);
return Number.isInteger(value) && value >= minimum && value <= maximum
? value
: undefined;
}
function validTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en", { timeZone: value.trim() }).format(0);
return value.trim().length > 0;
} catch {
return false;
}
}
function timeFromCron(cron: string): string {
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
if (match === null) return "";
return `${String(Number(match[2])).padStart(2, "0")}:${String(
Number(match[1]),
).padStart(2, "0")}`;
}
function baseSettings(base: unknown, fallback: ReMeSettings): ReMeSettings {
return isRecord(base) ? (base as unknown as ReMeSettings) : fallback;
}
function sameDraft(left: Draft, right: Draft): boolean {
return (Object.keys(left) as (keyof Draft)[]).every((key) =>
Object.is(left[key], right[key]),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function installStyles(): () => void {
const tag = document.createElement("style");
tag.dataset.pluginCss = "@agentscope-ai/reme-dsh-plugin/settings";
tag.textContent = styles;
tag.textContent = `${styles}\n${environmentStyles}`;
document.head.appendChild(tag);
return () => tag.remove();
}

View file

@ -0,0 +1,140 @@
import type { SettingsFieldSpec } from "@deepseek-ai/dsh-client-ui-primitives";
import { validEndpoint } from "../endpoint.js";
import { validTimezone } from "../scheduling.js";
// Re-exported for callers that imported it from this module before it moved.
export { validTimezone };
/*
* How each memory-settings field converts between its stored value and the text
* a control shows, in display and reset order.
*
* DSH's `SettingsFormModel` stages these drafts, reports which ones are invalid
* and writes them in one revision-fenced mutation; the table below is the only
* per-field code this plugin owns, and the field list and the field-name type
* are derived from it, so a field cannot be added in one place and forgotten in
* another.
*/
/**
* A whole number inside the range the Host accepts; empty clears the field.
*
* The field name stays a literal in the returned type, so the one spec table
* below keeps the closed union of setting names.
*/
function boundedInteger<Field extends string>(
field: Field,
minimum: number,
maximum: number,
): SettingsFieldSpec & { readonly field: Field } {
return {
field,
format: (value) => (typeof value === "number" ? String(value) : ""),
parse: (text) => {
const trimmed = text.trim();
if (trimmed === "") return { kind: "clear" };
const value = Number(trimmed);
if (!Number.isInteger(value) || value < minimum || value > maximum)
return undefined;
return { kind: "set", value };
},
};
}
/** A checkbox: the control shows and writes "true" or "false". */
function flag<Field extends string>(
field: Field,
): SettingsFieldSpec & { readonly field: Field } {
return {
field,
format: (value) => (value === true ? "true" : "false"),
parse: (text) => ({ kind: "set", value: text === "true" }),
};
}
/** "HH:MM" in the control, a five-field cron expression in the document. */
function timeOfDay<Field extends string>(
field: Field,
): SettingsFieldSpec & { readonly field: Field } {
return {
field,
format: (value) => (typeof value === "string" ? timeFromCron(value) : ""),
parse: (text) => {
const match = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
if (match === null) return undefined;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return undefined;
return { kind: "set", value: `${minute} ${hour} * * *` };
},
};
}
/** "HH:MM" for a daily `m h * * *` schedule; anything else shows nothing. */
export function timeFromCron(cron: string): string {
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
if (match === null) return "";
return `${String(Number(match[2])).padStart(2, "0")}:${String(
Number(match[1]),
).padStart(2, "0")}`;
}
/**
* The one definition of the settings fields: which exist, in which order, and
* how each one converts between its stored value and its control text.
*
* `as const` keeps every field name a string literal (helpers preserve theirs
* through a generic) and `satisfies` keeps each entry checked against DSH's
* spec, so `SettingField` remains a closed union of the names defined here.
*/
const SETTING_SPEC_DEFS = [
{
// The endpoint loses its trailing slashes on the way in, the way the
// client appends job names to it.
field: "endpoint",
format: (value: unknown) => (typeof value === "string" ? value : ""),
parse: (text: string) => {
const trimmed = text.trim().replace(/\/+$/, "");
return validEndpoint(trimmed)
? { kind: "set", value: trimmed }
: undefined;
},
},
boundedInteger("requestTimeoutMs", 1000, 120000),
boundedInteger("backgroundTimeoutMs", 1000, 3600000),
boundedInteger("shutdownTimeoutMs", 100, 60000),
boundedInteger("autoMemoryRetryMs", 1000, 60000),
boundedInteger("autoMemoryRetryMaxMs", 1000, 3600000),
flag("autoMemoryEnabled"),
boundedInteger("autoMemoryInterval", 1, 1000),
flag("autoDreamEnabled"),
timeOfDay("dreamCron"),
{
field: "dreamHint",
format: (value: unknown) => (typeof value === "string" ? value : ""),
parse: (text: string) => ({ kind: "set", value: text }),
},
flag("rootAgentsOnly"),
{
field: "language",
format: (value: unknown) => (value === "en" ? "en" : "zh"),
parse: (text: string) =>
text === "en" || text === "zh" ? { kind: "set", value: text } : undefined,
},
boundedInteger("searchLimit", 1, 50),
{
field: "timezone",
format: (value: unknown) => (typeof value === "string" ? value : ""),
parse: (text: string) =>
validTimezone(text.trim())
? { kind: "set", value: text.trim() }
: undefined,
},
] as const satisfies readonly SettingsFieldSpec[];
/** Every settings field name, in display and reset order. */
export type SettingField = (typeof SETTING_SPEC_DEFS)[number]["field"];
/** The specs `SettingsFormModel` stages drafts against, in display order. */
export const SETTINGS_SPECS: SettingsFieldSpec[] = [...SETTING_SPEC_DEFS];

View file

@ -294,10 +294,12 @@ export function ReMeStatusPage({
rpc,
t,
}: ReMeStatusPageProps): JSX.Element | null {
const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
const subscribe = useCallback(
(listener: () => void) => scope.subscribe(listener),
[scope],
);
const getSnapshot = useCallback(() => scope.getSnapshot(), [scope]);
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
const settings = snapshot.value;
const [tab, setTab] = useState<StatusTab>("overview");
const [data, setData] = useState<StatusData>({ loading: true, error: "" });
@ -1186,6 +1188,9 @@ function diagnosticClient(settings: ReMeSettings): ReMeClient {
endpoint: settings.endpoint,
requestTimeoutMs: settings.requestTimeoutMs,
backgroundTimeoutMs: settings.backgroundTimeoutMs,
// The memory runtime authenticates, so diagnostics must too; otherwise a
// gateway-protected service shows 401s that look like a broken workspace.
authToken: settings.authToken,
});
}
@ -1219,7 +1224,8 @@ function componentGroupLabel(group: string, t: StatusTranslator): string {
file_store: "fileStore",
keyword_index: "keywordIndex",
};
return labels[group] === undefined ? readableName(group) : t(labels[group]);
const key = Object.hasOwn(labels, group) ? labels[group] : undefined;
return key === undefined ? readableName(group) : t(key);
}
function componentFieldLabel(field: string, t: StatusTranslator): string {
@ -1237,7 +1243,8 @@ function componentFieldLabel(field: string, t: StatusTranslator): string {
vocab_size: "vocabulary",
memory: "memoryUsage",
};
return labels[field] === undefined ? readableName(field) : t(labels[field]);
const key = Object.hasOwn(labels, field) ? labels[field] : undefined;
return key === undefined ? readableName(field) : t(key);
}
function readableName(value: string): string {

View file

@ -31,5 +31,11 @@ export const styles = `
.reme-components-hero{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:linear-gradient(135deg,color-mix(in srgb,#21a366 6%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:18px 20px}.reme-components-hero h3{margin:3px 0 0;font-size:20px}.reme-components-hero p{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px}.reme-components-score{display:grid;grid-template-columns:auto auto;align-items:baseline}.reme-components-score strong{font-size:25px}.reme-components-score>span{color:var(--dsw-alias-label-tertiary);font-size:13px}.reme-components-score small{grid-column:1 / -1;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-status-components .reme-status-component-grid{margin-top:12px}.reme-status-component{position:relative;overflow:hidden;padding:15px}.reme-status-component:before{position:absolute;top:0;right:0;left:0;height:2px;background:var(--dsw-alias-label-dimmed);content:""}.reme-status-component.healthy:before{background:#21a366}.reme-component-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px!important}.reme-component-identity>span{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:10px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:700}.reme-component-identity>div{display:flex;min-width:0;flex-direction:column;gap:2px}
.reme-library-hero{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:13px;border:1px solid var(--dsw-alias-border-l2);border-bottom:0;border-radius:14px 14px 0 0;background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:15px 17px}.reme-library-hero.knowledge{background:linear-gradient(135deg,color-mix(in srgb,#7656c9 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3))}.reme-library-mark{display:grid;width:38px;height:38px;place-items:center;border-radius:11px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:700}.reme-library-hero h3{margin:0;font-size:15px}.reme-library-hero p{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px}.reme-library-count{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:0 4px;text-align:right}.reme-library-count strong{font-size:18px}.reme-library-count span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-library-count code{grid-column:1 / -1;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-file-search{position:sticky;z-index:1;top:-8px;display:flex;align-items:center;gap:6px;background:inherit;padding:0 0 8px}.reme-file-search input{width:100%;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:7px 9px;color:var(--dsw-alias-label-primary);font:inherit;font-size:10px}.reme-file-search input:focus{border-color:var(--dsw-alias-brand-primary);outline:0}.reme-file-search>span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header{position:sticky;z-index:1;top:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 92%,transparent);padding:14px 18px;backdrop-filter:blur(10px)}.reme-document-preview>header span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header h3{margin:2px 0;font-size:15px}.reme-document-preview>header code{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-frontmatter,.reme-document-content{padding:16px 18px}.reme-frontmatter{border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,transparent)}.reme-frontmatter h4,.reme-document-content>h4{margin:0 0 11px;color:var(--dsw-alias-label-tertiary);font-size:9px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.reme-frontmatter dl{display:grid;gap:8px;margin:0}.reme-frontmatter dl>div{display:grid;grid-template-columns:minmax(76px,max-content) minmax(0,1fr);gap:14px}.reme-frontmatter dt{color:var(--dsw-alias-label-tertiary);font:600 10px ui-monospace,SFMono-Regular,monospace}.reme-frontmatter dd{margin:0;overflow-wrap:anywhere;font-size:11px;line-height:1.5;white-space:pre-wrap}.reme-document-content{padding-bottom:38px}
@media(max-width:900px){.reme-status-metric-grid,.reme-status-dream-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.reme-settings-tabs{display:flex;gap:2px;margin-top:10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
.reme-settings-tabs button{position:relative;appearance:none;border:0;background:none;padding:9px 12px;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;cursor:pointer}
.reme-settings-tabs button:hover{color:var(--dsw-alias-label-primary)}
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
`;

View file

@ -19,6 +19,28 @@ export const Config = z.object({
"ReMe HTTP service URL",
assertEndpoint,
),
authToken: z
.string()
.default("")
.description(
"Optional bearer token for a self-hosted gateway in front of ReMe",
)
.volatile(),
pythonPath: z
.string()
.default("")
.description("Local Python interpreter selected in environment management")
.volatile(),
remeWorkspaceDir: z
.string()
.default("")
.description("ReMe workspace directory (empty means ~/.reme)")
.volatile(),
autoStart: z
.boolean()
.default(false)
.description("Start a stopped local ReMe service when DSH starts")
.volatile(),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
backgroundTimeoutMs: z
.natural()
@ -86,6 +108,10 @@ function checkedString(
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
endpoint: "http://127.0.0.1:2333",
authToken: "",
pythonPath: "",
remeWorkspaceDir: "",
autoStart: false,
requestTimeoutMs: 10000,
backgroundTimeoutMs: 3600000,
shutdownTimeoutMs: 5000,
@ -138,6 +164,19 @@ export function resolveConfig(
config.endpoint = stripTrailingSlashes(String(config.endpoint));
assertEndpoint(config.endpoint);
config.authToken =
typeof config.authToken === "string" ? config.authToken.trim() : "";
if (config.authToken && !/^[\x21-\x7e]{1,512}$/.test(config.authToken))
throw new TypeError(
"ReMe auth token must be 1-512 visible ASCII characters without newlines",
);
config.pythonPath =
typeof config.pythonPath === "string" ? config.pythonPath.trim() : "";
config.remeWorkspaceDir =
typeof config.remeWorkspaceDir === "string"
? config.remeWorkspaceDir.trim()
: "";
config.autoStart = config.autoStart === true;
const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`;
const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`;
config.autoMemoryQueueDir = resolve(

View file

@ -0,0 +1,627 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import { canHostService } from "../dependency.js";
import { interpreterKey } from "../interpreter-key.js";
import type { InstallStatus, RemeCheck } from "../types.js";
import { fill, type EnvTranslator } from "./locales.js";
import { StatusBadge } from "./presentation.js";
import { envCall, parseInstallStatus, parseRemeCheck } from "./rpc.js";
export interface CheckState {
phase: "idle" | "ready" | "error";
data?: RemeCheck;
error?: string;
/** The interpreter this result belongs to. */
forPath: string;
}
export interface InstallState {
phase: "idle" | "running" | "done";
/** Interpreter this state belongs to; another one cannot replace it. */
pythonPath: string;
/** Job this section polls; another installation cannot replace it. */
jobId?: string;
job?: InstallStatus;
error?: string;
/**
* Request generation. An answer from an older request never paints this
* card, so a late response cannot resurrect a job the user moved on from.
*/
generation: number;
}
/** The state of an interpreter that has nothing installed or running. */
const IDLE_INSTALL: InstallState = {
phase: "idle",
pythonPath: "",
generation: 0,
};
export interface DependencyState {
/** Check result, only when it belongs to the interpreter on screen. */
checkData: RemeCheck | undefined;
checkError: string | undefined;
checking: boolean;
install: InstallState;
/** Wall clock tick while an installation runs, for the elapsed label. */
now: number;
recheck(): void;
start(
upgrade: boolean,
indexUrl?: string,
forceReinstall?: boolean,
): Promise<void>;
cancel(): Promise<void>;
dismiss(): void;
}
/**
* The dependency section's asynchronous state: one reme-ai check per
* interpreter, and one installation job bound to its own interpreter, job id
* and request generation.
*/
export function useDependency(
rpc: StatusRpc,
options: {
pythonPath: string;
/** False for a remote endpoint, whose local steps are hidden. */
enabled?: boolean;
onInstalled(): void;
},
): DependencyState {
const { pythonPath, enabled = true, onInstalled } = options;
const [check, setCheck] = useState<CheckState>({
phase: "idle",
forPath: "",
});
const [install, setInstall] = useState<InstallState>(IDLE_INSTALL);
const [tick, setTick] = useState(0);
const [now, setNow] = useState(0);
const installed = useRef(onInstalled);
installed.current = onInstalled;
/** Monotonic request counter: only the newest answer may paint the card. */
const generation = useRef(0);
/** Interpreter the state on screen belongs to. */
const currentPath = useRef(pythonPath);
/**
* The installation on screen belongs to the interpreter on screen: a job for
* another interpreter is neither shown nor cancellable from here. The card
* and the actions read this one state, so they cannot disagree.
*/
const visibleInstall =
install.pythonPath === pythonPath
? install
: { ...IDLE_INSTALL, pythonPath };
useEffect(() => {
if (!enabled || !pythonPath) return;
const controller = new AbortController();
void (async () => {
try {
const value = await envCall(
rpc,
"checkReme",
{ pythonPath },
controller.signal,
);
if (controller.signal.aborted) return;
setCheck({
phase: "ready",
data: parseRemeCheck(value),
error: undefined,
forPath: pythonPath,
});
} catch (error) {
if (!controller.signal.aborted)
setCheck({
phase: "error",
error: safeError(error),
forPath: pythonPath,
});
}
})();
return () => controller.abort();
}, [rpc, pythonPath, tick, enabled]);
const recheck = useCallback(() => {
setCheck({ phase: "idle", forPath: "" });
setTick((current) => current + 1);
}, []);
/**
* The job finished. A successful install changed what the interpreter has,
* so the dependency check has to run again — otherwise the card keeps
* reporting "not installed" and Start stays disabled.
*/
const finishInstall = useCallback(
(status: InstallStatus, path: string) => {
if (status.phase !== "succeeded") return;
// The success belongs to the interpreter on screen; anything else is a
// job the user already moved on from.
if (currentPath.current !== path) return;
installed.current();
recheck();
},
[recheck],
);
// Progress belongs to an interpreter: switching interpreters must not keep
// showing (or cancelling) a job that was started for the previous one. The
// new generation retires every answer the previous interpreter is owed.
useEffect(() => {
currentPath.current = pythonPath;
const request = (generation.current += 1);
setInstall({ phase: "idle", pythonPath, generation: request });
}, [pythonPath]);
// Restoring a running job is per interpreter: a job started for another
// interpreter belongs to that interpreter's section, not this one. The Host
// looks the job up by interpreter, because two interpreters may install at
// the same time and the newest job overall is not necessarily this one.
useEffect(() => {
if (!enabled || !pythonPath) return;
const controller = new AbortController();
const request = generation.current;
void (async () => {
try {
const status = parseInstallStatus(
await envCall(
rpc,
"installStatus",
{ pythonPath },
controller.signal,
),
);
if (controller.signal.aborted) return;
// An installation the user started meanwhile owns the card now.
if (request !== generation.current) return;
if (!status || status.phase !== "running") return;
// The Host looks the job up by interpreter, with the path semantics of
// the path's own platform; the tab compares the same way, so a job the
// Host found is not dropped again here.
if (interpreterKey(status.pythonPath) !== interpreterKey(pythonPath))
return;
setInstall({
phase: "running",
pythonPath,
jobId: status.id,
job: status,
generation: request,
});
} catch {
// Restoring is best-effort; a later action reports real failures.
}
})();
return () => controller.abort();
}, [rpc, pythonPath, enabled]);
const jobId = install.jobId;
const installPath = install.pythonPath;
// The generation is not read in the effect: it is listed so that a replaced
// state restarts the poll instead of leaving the old one running.
const installGeneration = install.generation;
useEffect(() => {
// A running job always has an id; without one the Host would answer with
// the newest job overall, which may belong to another interpreter.
if (install.phase !== "running" || !jobId) return;
const controller = new AbortController();
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = async (): Promise<void> => {
try {
const status = parseInstallStatus(
await envCall(rpc, "installStatus", { jobId }, controller.signal),
);
if (cancelled) return;
if (!status) {
setInstall((current) => ({ ...current, phase: "idle" }));
return;
}
if (status.phase === "running") {
setInstall((current) => ({ ...current, job: status }));
timer = setTimeout(() => void poll(), 2000);
return;
}
setInstall((current) => ({ ...current, phase: "done", job: status }));
finishInstall(status, installPath);
} catch (error) {
if (cancelled) return;
setInstall((current) => ({
...current,
phase: "done",
error: safeError(error),
}));
}
};
void poll();
return () => {
cancelled = true;
controller.abort();
if (timer) clearTimeout(timer);
};
}, [
install.phase,
jobId,
installPath,
installGeneration,
rpc,
finishInstall,
]);
useEffect(() => {
if (install.phase !== "running") return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [install.phase]);
const start = useCallback(
async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
// The card shows the derived state, so the guard reads it too: a job for
// another interpreter must not block this one.
if (!pythonPath || visibleInstall.phase === "running") return;
const target = pythonPath;
// Every request gets its own generation, so a late answer cannot
// replace a newer installation or survive an interpreter switch.
const request = (generation.current += 1);
setInstall({ phase: "idle", pythonPath: target, generation: request });
try {
const status = parseInstallStatus(
await envCall(rpc, "install", {
pythonPath: target,
upgrade,
...(indexUrl ? { indexUrl } : {}),
...(forceReinstall ? { forceReinstall } : {}),
}),
);
// The answer belongs to the request that asked for it.
if (request !== generation.current || target !== currentPath.current)
return;
if (!status) {
setInstall({
phase: "idle",
pythonPath: target,
generation: request,
});
return;
}
setInstall({
phase: status.phase === "running" ? "running" : "done",
pythonPath: target,
jobId: status.id,
job: status,
generation: request,
});
if (status.phase !== "running") finishInstall(status, target);
} catch (error) {
if (request !== generation.current || target !== currentPath.current)
return;
setInstall({
phase: "done",
pythonPath: target,
generation: request,
error: safeError(error),
});
}
},
[finishInstall, pythonPath, rpc, visibleInstall.phase],
);
const cancel = useCallback(async () => {
if (!jobId) return;
try {
await envCall(rpc, "installCancel", { jobId });
} catch {
// Polling reflects the final phase either way.
}
}, [jobId, rpc]);
const dismiss = useCallback(() => {
const request = (generation.current += 1);
setInstall({
phase: "idle",
pythonPath: currentPath.current,
generation: request,
});
}, []);
const current = check.forPath === pythonPath ? check : undefined;
return {
checkData: current?.data,
checkError: current?.error,
checking: Boolean(pythonPath) && check.forPath !== pythonPath,
// The installation on screen belongs to the interpreter on screen.
install: visibleInstall,
now,
recheck,
start,
cancel,
dismiss,
};
}
function elapsedText(job: InstallStatus, now: number): string {
const started = Date.parse(job.startedAt);
if (!Number.isFinite(started) || now < started) return "";
const seconds = Math.floor((now - started) / 1000);
const minutes = Math.floor(seconds / 60);
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
}
/** Step 2: reme-ai in the selected interpreter, and its one-click install. */
export function DependencySection({
t,
pythonPath,
dependency,
}: {
t: EnvTranslator;
pythonPath: string;
dependency: DependencyState;
}): JSX.Element {
const { checkData, checkError, checking, install, now } = dependency;
const [confirming, setConfirming] = useState(false);
const [mirror, setMirror] = useState("");
// The confirm copy follows the CTA: a Reinstall downloads again with
// `--force-reinstall`, so the first-install wording would be misleading.
const reinstall = checkData?.installed === true && checkData.versionOk;
const badge = !checkData
? undefined
: !checkData.installed
? { tone: "progress" as const, text: t("depMissing") }
: canHostService(checkData)
? { tone: "ok" as const, text: t("depReady") }
: checkData.versionOk
? { tone: "progress" as const, text: t("coreMissingBadge") }
: { tone: "progress" as const, text: t("depUpgrade") };
return (
<section className="reme-env-card">
<header>
<span className="reme-env-step" aria-hidden="true">
2
</span>
<div>
<h4>{t("dependencyTitle")}</h4>
<p>{t("dependencyHint")}</p>
</div>
{badge ? (
<aside>
<StatusBadge tone={badge.tone}>{badge.text}</StatusBadge>
</aside>
) : null}
</header>
<div className="reme-env-card-body">
<div className="reme-env-factrow">
<span className="reme-env-field-label">{t("interpreterLabel")}</span>
<code className="reme-env-path" title={pythonPath}>
{pythonPath || t("noInterpreter")}
</code>
<button
type="button"
className="reme-settings-button"
disabled={!pythonPath}
onClick={dependency.recheck}
>
{t("recheck")}
</button>
</div>
{!pythonPath ? (
<p className="reme-settings-muted">{t("selectInterpreterFirst")}</p>
) : checking ? (
<p className="reme-settings-muted">{t("checkingReme")}</p>
) : null}
{checkError ? (
<p className="reme-settings-error">
{t("checkRemeFailed")}
{checkError ? `: ${checkError}` : ""}
</p>
) : null}
{checkData?.error ? (
<>
<p className="reme-settings-error">{t("checkRemeFailed")}</p>
<details className="reme-settings-details">
<summary>{t("errorsTitle")}</summary>
<pre>{checkData.error}</pre>
</details>
</>
) : null}
{checkData ? (
<div className="reme-env-metrics">
<div className="reme-env-metric">
<span>{t("remeLabel")}</span>
<div>
{checkData.installed ? (
<>
<StatusBadge
tone={checkData.versionOk ? "ok" : "progress"}
>{`reme-ai ${checkData.version}`}</StatusBadge>
{checkData.versionOk ? null : (
<small>{t("upgradeRecommended")}</small>
)}
</>
) : (
<StatusBadge tone="bad">{t("notInstalled")}</StatusBadge>
)}
</div>
</div>
<div className="reme-env-metric">
<span>{t("coreLabel")}</span>
<div>
{checkData.installed ? (
<StatusBadge tone={checkData.hasCore ? "ok" : "progress"}>
{checkData.hasCore ? t("coreOk") : t("coreMissing")}
</StatusBadge>
) : (
<span className="reme-settings-muted">—</span>
)}
</div>
</div>
<div className="reme-env-metric">
<span>{t("cliLabel")}</span>
<div>
{checkData.installed ? (
checkData.cliPath ? (
<code
className="reme-env-metric-path"
title={checkData.cliPath}
>
{checkData.cliPath}
</code>
) : (
<StatusBadge tone="progress">
{t("cliFallback")}
</StatusBadge>
)
) : (
<span className="reme-settings-muted">—</span>
)}
</div>
</div>
</div>
) : null}
{checkData && install.phase !== "running" ? (
<div className="reme-env-cta">
<label className="reme-env-mirror">
<span>{t("mirrorLabel")}</span>
<select
value={mirror}
onChange={(event) => setMirror(event.target.value)}
>
<option value="">{t("mirrorDefault")}</option>
<option value="https://pypi.org/simple">
{t("mirrorPypi")}
</option>
<option value="https://pypi.tuna.tsinghua.edu.cn/simple">
{t("mirrorTsinghua")}
</option>
<option value="https://mirrors.aliyun.com/pypi/simple">
{t("mirrorAliyun")}
</option>
</select>
</label>
{confirming ? (
<div className="reme-env-confirm">
<p>
{fill(t(reinstall ? "reinstallConfirm" : "installConfirm"), {
path: pythonPath,
})}
</p>
<button
type="button"
className="reme-settings-save"
onClick={() => {
setConfirming(false);
// Fresh install: no upgrade. Outdated: upgrade. Current
// but suspect (Reinstall): force pip to reinstall.
// The mirror choice reaches pip as `--index-url`; without
// it the selector would be decorative.
void dependency.start(
checkData.installed && !checkData.versionOk,
mirror || undefined,
reinstall,
);
}}
>
{t("confirmInstall")}
</button>
<button
type="button"
className="reme-settings-discard"
onClick={() => setConfirming(false)}
>
{t("cancel")}
</button>
</div>
) : checkData.installed && checkData.versionOk ? (
<button
type="button"
className="reme-settings-button"
onClick={() => setConfirming(true)}
>
{t("reinstall")}
</button>
) : checkData.installed ? (
<button
type="button"
className="reme-settings-save"
onClick={() => setConfirming(true)}
>
{t("upgradeButton")}
</button>
) : (
<button
type="button"
className="reme-settings-save"
onClick={() => setConfirming(true)}
>
{t("installButton")}
</button>
)}
<p className="reme-settings-muted reme-env-hint-inline">
{t("condaHint")}
</p>
</div>
) : null}
{install.phase === "running" && install.job ? (
<div className="reme-env-progress">
<div className="reme-env-row">
<StatusBadge tone="progress">
{t("installing")}
{elapsedText(install.job, now)
? ` · ${elapsedText(install.job, now)}`
: ""}
</StatusBadge>
<button
type="button"
className="reme-settings-discard"
disabled={install.job.cancelling === true}
onClick={() => void dependency.cancel()}
>
{t(install.job.cancelling ? "cancelling" : "cancelInstall")}
</button>
</div>
{install.job.cancelling ? (
<p className="reme-settings-muted">{t("cancellingHint")}</p>
) : null}
<pre className="reme-env-log">
{install.job.logTail || t("waitingForOutput")}
</pre>
</div>
) : null}
{install.phase === "done" ? (
<div className="reme-env-result">
{install.job?.phase === "succeeded" ? (
<p className="reme-settings-success">{t("installSucceeded")}</p>
) : null}
{install.job?.phase === "cancelled" ? (
<p className="reme-settings-muted">{t("installCancelled")}</p>
) : null}
{install.job?.phase === "failed" ? (
<>
<p className="reme-settings-error">{t("installFailed")}</p>
{install.job.logTail ? (
<pre className="reme-env-log">{install.job.logTail}</pre>
) : null}
<p className="reme-settings-muted">{install.job.logPath}</p>
</>
) : null}
{install.error ? (
<p className="reme-settings-error">{install.error}</p>
) : null}
<button
type="button"
className="reme-settings-button"
onClick={() => {
setConfirming(false);
dependency.dismiss();
}}
>
{t("dismiss")}
</button>
</div>
) : null}
</div>
</section>
);
}

View file

@ -0,0 +1,407 @@
/** Copy for the Environment tab, registered under `reme.environment`. */
export const envEn = {
endpointLabel: "Service URL",
checking: "Checking…",
connected: "Connected",
disconnected: "Not connected",
retest: "Test connection",
remoteNotice:
"This service URL is not a loopback URL at the origin root, so only its connection is probed. A gateway mounted under a path prefix cannot be managed locally.",
pythonTitle: "Local Python",
pythonHint:
"ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
detecting: "Detecting Python environments…",
detectFailed: "Detection failed",
refresh: "Refresh",
emptyTitle: "No usable Python found",
emptyHint:
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
miniconda: "Miniconda download",
recommended: "recommended",
tooOld: "Needs 3.11+",
originConda: "Conda ({name})",
originPythonOrg: "python.org installer",
originStore: "Microsoft Store",
originVenv: "venv environment",
originSystem: "System PATH",
originUnknown: "Unknown origin",
errorsTitle: "Probe details",
dependencyTitle: "ReMe dependency",
dependencyHint:
"The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
interpreterLabel: "Interpreter",
noInterpreter: "No interpreter selected",
selectInterpreterFirst: "Select or install a Python interpreter above first.",
recheck: "Check",
checkingReme: "Checking ReMe installation…",
checkRemeFailed: "ReMe check failed",
remeLabel: "reme-ai",
coreLabel: "[core] extras",
cliLabel: "reme CLI",
notInstalled: "Not installed",
upgradeRecommended: "upgrade recommended",
coreOk: "Available",
coreMissing: "Missing (required to start the service)",
coreMissingBadge: "core missing",
cliFallback: "python wrapper fallback",
installButton: "Install reme-ai[core]",
upgradeButton: "Upgrade reme-ai",
reinstall: "Reinstall reme-ai[core]",
installConfirm:
"Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
reinstallConfirm:
"Reinstalls reme-ai[core] into {path} with --force-reinstall. Packages are downloaded again; progress is shown below.",
confirmInstall: "Confirm install",
cancel: "Cancel",
installing: "Installing",
cancelInstall: "Cancel install",
cancelling: "Cancelling…",
cancellingHint:
"The interpreter stays locked until the install process has actually stopped.",
waitingForOutput: "Waiting for pip output…",
installSucceeded:
"ReMe installed. Search, automatic memory and memory consolidation are now available.",
installFailed: "Installation failed. See the log below.",
installCancelled: "Installation cancelled.",
dismiss: "Dismiss",
mirrorLabel: "Package index",
mirrorDefault: "Default (pip config)",
mirrorPypi: "PyPI",
mirrorTsinghua: "Tsinghua",
mirrorAliyun: "Aliyun",
condaHint:
"In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
configHint:
"ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
workspaceDir: "Workspace directory",
workspaceDirHint:
"Empty means ~/.reme. Restart the service after changing it.",
saveWorkspace: "Save path",
envLoading: "Loading .env…",
envMissing: "No .env yet; saving creates it.",
envPath: "File",
llmGroup: "LLM (auto memory / consolidation)",
embeddingGroup: "Embedding",
backendLabel: "Backend",
modelLabel: "Model",
apiKeyLabel: "API key",
baseUrlLabel: "Base URL",
secretSet: "Set {masked}; leave blank to keep",
secretUnset: "Not set",
embeddingHint:
"ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
saveEnv: "Save to .env",
savingEnv: "Saving…",
savedEnv: "Saved to {path}",
savedEnvPending: "Saved to {path}. Newer edits are still pending.",
saveEnvFailed: "Saving failed",
testLlmButton: "Test LLM connection",
testingLlm: "Testing…",
testLlmSaveFirst:
"Save the .env first: the test uses the configuration that is saved on disk.",
llmOk: "LLM reachable ({ms} ms)",
llmFailed: "LLM test failed",
serviceHint:
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
serviceBadgeStarting: "Starting…",
serviceBadgeConflict: "Port occupied",
serviceBadgeUnhealthy: "Not healthy",
serviceBadgeStopped: "Stopped",
serviceBadgeStale: "Last launch not ready",
serviceRunning: "Running",
startService: "Start service",
stopService: "Stop service",
startTimeout: "The service did not become healthy within 120 seconds",
startFailedStopped:
"The service exited before it became healthy. Open the log below.",
startErrorOtherEndpoint:
"A service start for another endpoint is still in progress.",
startErrorConflict: "Another process is already listening on this endpoint.",
startErrorOtherEndpointRunning:
"A service for another endpoint is still running; stop it there first.",
startErrorPreviousNotStopped:
"The previous service could not be stopped; check the log.",
startErrorUnverifiedRecord:
"The recorded service identity could not be verified, so it was left untouched; stop it manually, then retry.",
startErrorUnverifiedRecordPid:
"The recorded service identity could not be verified (PID {pid}), so it was left untouched; stop it manually, then retry.",
startErrorNoPython: "Select a Python interpreter first.",
startErrorNotInstalled:
"reme-ai is not installed in the selected interpreter.",
startErrorVersionTooOld:
"The installed reme-ai is too old; upgrade it first.",
startErrorCoreMissing:
"AgentScope is missing; install reme-ai[core] before starting the service.",
startErrorFailed: "The service could not be started; check the log.",
stopNoRecord: "No service was started by this plugin.",
stopForeign: "The recorded service does not belong to this endpoint.",
stopUnknownIdentity:
"The recorded process could not be verified, so it was left running.",
stopNotExited:
"The service did not exit; its record was kept so you can retry.",
stopRecordChanged:
"The recorded service changed since it was inspected, so the offer was withdrawn. Run the action again to see the current record.",
stopConfirm:
"Stop the ReMe service? Automatic memory in flight may need a retry.",
foreignNote:
"This service was started outside DSH; its state is shown but it is not managed here.",
conflictNote:
"Another program occupies this port. Change the service URL port in Memory settings.",
conflictManagedNote:
"This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
staleNote:
"A recorded launch is still running but never became healthy. Stop it, then start again.",
forgetRecord: "Forget this record",
forgetHint:
"The plugin cannot clear this service record: Start and Stop both refuse to act on it. Forgetting it deletes only the record; the process keeps running.",
forgetConfirmRecord:
"Forget the service recorded for {endpoint}? This only deletes the plugin's record; it does not stop the process. Continue only if the process is already gone or you will manage it yourself.",
forgetConfirmPid: "The record describes process {pid}.",
healthErrorHttp:
"The endpoint returned an HTTP error, not a ReMe health report.",
healthErrorNotReme:
"The endpoint answered, but not with a ReMe health report.",
healthErrorReme: "The service reported an error during the health check.",
healthErrorUnreachable:
"The endpoint could not be reached from this machine.",
viewLogs: "View log",
logEmpty: "The service log is empty.",
autoStartLabel: "Start the service automatically with DSH",
managedNote: "Managed by this plugin · PID {pid}",
serviceError: "Service operation failed",
settingsWriteFailed:
"The setting was not saved. The form may have changed in another window; retry.",
configTitle: "ReMe parameters",
controlTitle: "Service control",
heroEyebrow: "ReMe local runtime",
heroReady: "Ready — memory runs locally",
heroSetup: "Finish the setup in the steps below",
heroService: "Service",
heroPython: "Python",
heroReme: "ReMe",
noneSelected: "not selected",
noneDetected: "not detected",
envCount: "{count} environments",
envRemeCount: "{count} with ReMe",
envOthers: "{count} other environments",
scannedAt: "Detected at {time}",
currentInterpreter: "Current interpreter",
notInDetection: "not detected",
savingSelection: "Saving…",
changeInterpreter: "Change interpreter",
collapseList: "Collapse list",
searchPlaceholder: "Filter by version, origin or path",
noMatch: "No environment matches the filter.",
showAllEnvs: "Show all {count}",
showFewer: "Show fewer",
depReady: "Ready",
depUpgrade: "Upgrade available",
depMissing: "Not installed",
expand: "Expand",
collapse: "Collapse",
recommendWhyReme:
"Already has a supported reme-ai, and it is a dedicated conda environment.",
recommendWhyRemeAny: "Already has a supported reme-ai.",
recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.",
recommendWhyFallback: "Fallback: the first usable interpreter.",
};
export const envZh: typeof envEn = {
endpointLabel: "服务地址",
checking: "正在检查…",
connected: "已连接",
disconnected: "未连接",
retest: "测试连接",
remoteNotice:
"当前服务地址不是本机根路径地址,仅探测其连通性;带路径前缀的网关地址不支持本地环境管理。",
pythonTitle: "本地 Python 环境",
pythonHint:
"ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
detecting: "正在探测本地 Python 环境…",
detectFailed: "探测失败",
refresh: "重新探测",
emptyTitle: "未检测到可用的 Python 环境",
emptyHint:
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
miniconda: "下载 Miniconda",
recommended: "推荐",
tooOld: "需要 3.11+",
originConda: "Conda({name})",
originPythonOrg: "python.org 安装器",
originStore: "Microsoft Store",
originVenv: "venv 虚拟环境",
originSystem: "系统 PATH",
originUnknown: "来源未知",
errorsTitle: "探测细节",
dependencyTitle: "ReMe 依赖",
dependencyHint:
"本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
interpreterLabel: "解释器",
noInterpreter: "尚未选择解释器",
selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
recheck: "检测",
checkingReme: "正在检测 ReMe 安装…",
checkRemeFailed: "ReMe 检测失败",
remeLabel: "reme-ai",
coreLabel: "[core] 依赖",
cliLabel: "reme CLI",
notInstalled: "未安装",
upgradeRecommended: "建议升级",
coreOk: "可用",
coreMissing: "缺失(启动服务所必需)",
coreMissingBadge: "缺少 core",
cliFallback: "回退到 python 包装",
installButton: "安装 reme-ai[core]",
upgradeButton: "升级 reme-ai",
reinstall: "重装 reme-ai[core]",
installConfirm:
"将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
reinstallConfirm:
"将使用 --force-reinstall 重新安装到 {path},依赖会重新下载,进度见下方日志。",
confirmInstall: "确认安装",
cancel: "取消",
installing: "正在安装",
cancelInstall: "取消安装",
cancelling: "正在取消…",
cancellingHint: "在安装进程真正退出之前,该解释器会一直保持占用。",
waitingForOutput: "等待 pip 输出…",
installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
installFailed: "安装失败,请查看下方日志。",
installCancelled: "安装已取消。",
dismiss: "关闭",
mirrorLabel: "软件源",
mirrorDefault: "默认(pip 配置)",
mirrorPypi: "PyPI 官方",
mirrorTsinghua: "清华镜像",
mirrorAliyun: "阿里云镜像",
condaHint:
"conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
configHint:
"ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
workspaceDir: "Workspace 目录",
workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
saveWorkspace: "保存路径",
envLoading: "正在读取 .env…",
envMissing: "尚未创建 .env;保存后自动创建。",
envPath: "文件",
llmGroup: "LLM(自动记忆 / 记忆整理)",
embeddingGroup: "Embedding",
backendLabel: "后端",
modelLabel: "模型",
apiKeyLabel: "API Key",
baseUrlLabel: "Base URL",
secretSet: "已设置 {masked};留空保持不变",
secretUnset: "未设置",
embeddingHint:
"ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
saveEnv: "保存到 .env",
savingEnv: "正在保存…",
savedEnv: "已保存到 {path}",
savedEnvPending: "已保存到 {path}。仍有更新的修改待保存。",
saveEnvFailed: "保存失败",
testLlmButton: "测试 LLM 连接",
testingLlm: "正在测试…",
testLlmSaveFirst: "请先保存 .env:测试使用的是磁盘上已保存的配置。",
llmOk: "LLM 连接正常({ms} ms)",
llmFailed: "LLM 测试失败",
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
serviceBadgeStarting: "启动中…",
serviceBadgeConflict: "端口被占用",
serviceBadgeUnhealthy: "未就绪",
serviceBadgeStopped: "已停止",
serviceBadgeStale: "上次启动未就绪",
serviceRunning: "运行中",
startService: "启动服务",
stopService: "停止服务",
startTimeout: "服务在 120 秒内未就绪",
startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
startErrorConflict: "该端口已被其他程序占用。",
startErrorOtherEndpointRunning:
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
startErrorUnverifiedRecord:
"无法验证记录中的进程身份,已保持其运行;请手动停止后重试。",
startErrorUnverifiedRecordPid:
"无法验证记录中的进程身份(PID {pid}),已保持其运行;请手动停止后重试。",
startErrorNoPython: "请先选择一个 Python 解释器。",
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
startErrorCoreMissing: "缺少 AgentScope,请先安装 reme-ai[core] 再启动服务。",
startErrorFailed: "服务未能启动,请查看日志。",
stopNoRecord: "本插件没有启动过该服务。",
stopForeign: "记录中的服务不属于当前服务地址。",
stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
stopNotExited: "服务未退出;已保留记录,可稍后重试。",
stopRecordChanged:
"记录的服务在检查之后发生了变化,因此已撤回该操作。请重新执行一次操作以查看当前记录。",
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
conflictManagedNote:
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
staleNote: "上次启动的进程仍在运行,但从未就绪;请停止它后重新启动。",
forgetRecord: "忘记这条记录",
forgetHint:
"本插件无法清除这条服务记录:启动与停止都会拒绝操作它。忘记记录只删除记录,进程仍会继续运行。",
forgetConfirmRecord:
"忘记 {endpoint} 上记录的服务?此操作只删除本插件的记录,不会结束进程。请确认进程已结束或你将自行管理后再继续。",
forgetConfirmPid: "该记录描述的是进程 {pid}。",
healthErrorHttp: "服务地址返回了 HTTP 错误,而不是 ReMe 健康报告。",
healthErrorNotReme: "服务地址有响应,但返回的不是 ReMe 健康报告。",
healthErrorReme: "服务在健康检查中报告了错误。",
healthErrorUnreachable: "无法从本机连接到该服务地址。",
viewLogs: "查看日志",
logEmpty: "服务日志为空。",
autoStartLabel: "随 DSH 启动时自动拉起服务",
managedNote: "由本插件托管 · PID {pid}",
serviceError: "服务操作失败",
settingsWriteFailed: "设置未保存;表单可能已在其他窗口更新,请重试。",
configTitle: "ReMe 参数",
controlTitle: "服务控制",
heroEyebrow: "ReMe 本地运行时",
heroReady: "已就绪,记忆保存在本地",
heroSetup: "按下方步骤完成配置",
heroService: "服务",
heroPython: "Python",
heroReme: "ReMe",
noneSelected: "未选择",
noneDetected: "未探测",
envCount: "{count} 个环境",
envRemeCount: "{count} 个已装 ReMe",
envOthers: "另有 {count} 个环境",
scannedAt: "探测于 {time}",
currentInterpreter: "当前解释器",
notInDetection: "不在探测结果中",
savingSelection: "正在保存…",
changeInterpreter: "更换解释器",
collapseList: "收起列表",
searchPlaceholder: "按版本、来源或路径筛选",
noMatch: "没有匹配的环境。",
showAllEnvs: "显示全部 {count} 个",
showFewer: "收起多余项",
depReady: "就绪",
depUpgrade: "可升级",
depMissing: "未安装",
expand: "展开",
collapse: "收起",
recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。",
recommendWhyRemeAny: "已安装可用的 reme-ai。",
recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。",
recommendWhyFallback: "兜底选择:第一个可用的解释器。",
};
export type EnvTranslator = (key: keyof typeof envEn) => string;
/** One translated string key of this tab. */
export type EnvKey = keyof typeof envEn;
/** Fill `{placeholder}` tokens in a translated string. */
export function fill(
text: string,
values: Record<string, string | number>,
): string {
return text.replace(/\{(\w+)\}/g, (match, name: string) =>
name in values ? String(values[name]) : match,
);
}

View file

@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import type { PythonEnv, PythonOrigin } from "../types.js";
import type { EnvTranslator } from "./locales.js";
/** Tone of a status chip: neutral, healthy, broken, or in progress. */
export type BadgeTone = "" | "ok" | "bad" | "progress";
export interface Badge {
tone: BadgeTone;
text: string;
}
/** One dot-plus-text chip, shared by the hero, the cards and the metrics. */
export function StatusBadge({
tone,
children,
}: {
tone: BadgeTone;
children: ReactNode;
}): JSX.Element {
return (
<span className={`reme-status-badge ${tone}`}>
<i />
{children}
</span>
);
}
/** One pill used for origins, versions and capability markers. */
export function Tag({
tone = "",
title,
children,
}: {
tone?: "" | "ok" | "bad" | "warn" | "brand";
title?: string;
children: ReactNode;
}): JSX.Element {
return (
<span className={`reme-env-tag ${tone}`} title={title}>
{children}
</span>
);
}
export function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
switch (origin.kind) {
case "conda":
return t("originConda").replace("{name}", origin.envName);
case "python.org":
return t("originPythonOrg");
case "windows-store":
return t("originStore");
case "venv":
return t("originVenv");
case "system":
return t("originSystem");
default:
return t("originUnknown");
}
}
/** Human-readable reason behind the "recommended" marker. */
export function recommendReason(t: EnvTranslator, env: PythonEnv): string {
const named = env.origin.kind === "conda" && env.origin.envName !== "base";
if (env.reme?.installed)
return t(named ? "recommendWhyReme" : "recommendWhyRemeAny");
return t(named ? "recommendWhyNamed" : "recommendWhyFallback");
}
/** The reme-ai, recommendation and version pills one interpreter carries. */
export function InterpreterBadges({
t,
env,
suggested,
}: {
t: EnvTranslator;
env: PythonEnv;
suggested: string;
}): JSX.Element {
const outdated = env.reme?.versionOk === false;
return (
<>
{env.reme?.installed ? (
<Tag
tone={outdated ? "warn" : "ok"}
title={outdated ? t("upgradeRecommended") : undefined}
>{`ReMe ${env.reme.version}`}</Tag>
) : null}
{env.exe === suggested ? (
<Tag tone="brand" title={recommendReason(t, env)}>
{t("recommended")}
</Tag>
) : null}
{env.ok ? null : <Tag tone="bad">{t("tooOld")}</Tag>}
</>
);
}
/** Label, control and hint of one settings field. */
export function EnvField({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}): JSX.Element {
return (
<div className="reme-settings-field">
<label>{label}</label>
{children}
{hint ? <small>{hint}</small> : null}
</div>
);
}

View file

@ -0,0 +1,340 @@
import { useCallback, useEffect, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import type { PythonDetection, PythonEnv } from "../types.js";
import { fill, type EnvTranslator } from "./locales.js";
import { InterpreterBadges, originLabel } from "./presentation.js";
import { envCall, parseDetection } from "./rpc.js";
const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
/** Interpreter rows shown before the list asks to expand. */
const ENV_PAGE = 6;
export interface DetectionState {
phase: "loading" | "ready" | "error";
data?: PythonDetection;
error?: string;
}
export interface PythonSelection {
detection: DetectionState;
/** Interpreter the Host recommends for this machine. */
suggested: string;
/** Selected interpreter from settings, or the recommendation when unset. */
selectedPath: string;
selectedEnv: PythonEnv | undefined;
/** Interpreter path whose selection is being written. */
saving: string;
/** Probe again; the Host caches detection for a minute. */
refresh(): void;
select(env: PythonEnv): void;
}
/**
* The interpreter section's asynchronous state: one detection probe per
* refresh, plus the selection write that settings owns.
*/
export function usePython(
rpc: StatusRpc,
options: {
pythonPath: string;
writable: boolean;
/** False for a remote endpoint, whose local steps are hidden. */
enabled?: boolean;
onSelect(exe: string): Promise<unknown>;
},
): PythonSelection {
const { pythonPath, writable, enabled = true, onSelect } = options;
const [detection, setDetection] = useState<DetectionState>({
phase: "loading",
});
const [tick, setTick] = useState(0);
const [saving, setSaving] = useState("");
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
void (async () => {
try {
const value = await envCall(
rpc,
"python",
{ refresh: tick > 0 },
controller.signal,
);
if (controller.signal.aborted) return;
setDetection({ phase: "ready", data: parseDetection(value) });
} catch (error) {
if (!controller.signal.aborted)
setDetection((current) => ({
phase: "error",
error: safeError(error),
...(current.data ? { data: current.data } : {}),
}));
}
})();
return () => controller.abort();
}, [rpc, tick, enabled]);
// A refresh keeps the last detection data, so the selected interpreter (and
// the install/progress panels keyed off it) does not flash away while the
// new probe is in flight.
const detected = detection.data;
const suggested = detected?.suggested ?? "";
const selectedPath = pythonPath || suggested;
const refresh = useCallback(() => {
setDetection((current) => ({
phase: "loading",
...(current.data ? { data: current.data } : {}),
}));
setTick((current) => current + 1);
}, []);
const select = useCallback(
(env: PythonEnv) => {
if (!writable || saving) return;
setSaving(env.exe);
void onSelect(env.exe)
.catch(() => undefined)
.finally(() => setSaving(""));
},
[onSelect, saving, writable],
);
return {
detection,
suggested,
selectedPath,
selectedEnv: detected?.envs.find((env) => env.exe === selectedPath),
saving,
refresh,
select,
};
}
/** Step 1: the interpreter a local service runs on. */
export function PythonSection({
t,
selection,
writable,
}: {
t: EnvTranslator;
selection: PythonSelection;
writable: boolean;
}): JSX.Element {
const { detection, suggested, selectedPath, saving } = selection;
// A refresh keeps the last detection data, so the list must keep rendering it
// instead of flashing "no usable Python" while the new probe is in flight.
const detected = detection.data;
const envs = detected?.envs ?? [];
const [listOpen, setListOpen] = useState(false);
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const selectedEnv = selection.selectedEnv;
const others = envs.filter((env) => env.exe !== selectedPath);
// The selected interpreter stays in the radio group, checked and disabled,
// so assistive technology can hear which one is current. Dropping it from the
// group left every radio unchecked, which conveys no selection at all.
const listed = selectedEnv ? [selectedEnv, ...others] : others;
const filter = query.trim().toLowerCase();
const matches = filter
? listed.filter((env) =>
`${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
.toLowerCase()
.includes(filter),
)
: listed;
const visible = showAll ? matches : matches.slice(0, ENV_PAGE);
const hidden = matches.length - visible.length;
const withReme = envs.filter((env) => env.reme?.installed === true).length;
return (
<section className="reme-env-card">
<header>
<span className="reme-env-step" aria-hidden="true">
1
</span>
<div>
<h4>{t("pythonTitle")}</h4>
<p>{t("pythonHint")}</p>
</div>
{envs.length > 0 ? (
<aside>
<span className="reme-env-count">
{fill(t("envCount"), { count: envs.length })}
{withReme > 0
? ` · ${fill(t("envRemeCount"), { count: withReme })}`
: ""}
</span>
</aside>
) : null}
</header>
<div className="reme-env-card-body">
{detection.phase === "loading" ? (
<p className="reme-settings-muted">{t("detecting")}</p>
) : null}
{detection.phase === "error" ? (
<p className="reme-settings-error">
{t("detectFailed")}
{detection.error ? `: ${detection.error}` : ""}
</p>
) : null}
{(detection.phase === "error" && !detected) ||
(detection.phase === "ready" && envs.length === 0) ? (
<div className="reme-env-hint">
<strong>{t("emptyTitle")}</strong>
<span>{t("emptyHint")}</span>
<a
className="reme-settings-button"
href={MINICONDA_URL}
target="_blank"
rel="noreferrer"
>
{t("miniconda")}
</a>
</div>
) : null}
{selectedEnv ? (
<div className="reme-env-picked">
<span className="reme-env-picked-mark" aria-hidden="true" />
<span className="reme-env-picked-main">
<span className="reme-env-title">
<strong>{`Python ${selectedEnv.version || "?"}`}</strong>
<span className="reme-env-tag">
{originLabel(t, selectedEnv.origin)}
</span>
<InterpreterBadges
t={t}
env={selectedEnv}
suggested={suggested}
/>
</span>
<code className="reme-env-path" title={selectedEnv.exe}>
{selectedEnv.exe}
</code>
</span>
{saving === selectedEnv.exe ? (
<span className="reme-settings-muted">
{t("savingSelection")}
</span>
) : null}
</div>
) : selectedPath ? (
<div className="reme-env-picked">
<span className="reme-env-picked-mark" aria-hidden="true" />
<span className="reme-env-picked-main">
<span className="reme-env-title">
<strong>{t("currentInterpreter")}</strong>
<span className="reme-env-tag bad">{t("notInDetection")}</span>
</span>
<code className="reme-env-path" title={selectedPath}>
{selectedPath}
</code>
</span>
</div>
) : null}
{others.length > 0 ? (
<div className="reme-env-picker">
<div className="reme-env-picker-bar">
<button
type="button"
className="reme-settings-button"
onClick={() => setListOpen((open) => !open)}
>
{t(listOpen ? "collapseList" : "changeInterpreter")}
</button>
{listOpen ? (
<input
className="reme-env-search"
value={query}
placeholder={t("searchPlaceholder")}
onChange={(event) => {
setQuery(event.target.value);
setShowAll(false);
}}
/>
) : (
<span className="reme-settings-muted">
{fill(t("envOthers"), { count: others.length })}
</span>
)}
</div>
{listOpen ? (
<>
{visible.length > 0 ? (
<div
className="reme-env-options"
role="radiogroup"
aria-label={t("pythonTitle")}
>
{visible.map((env) => (
<label key={env.exe} className="reme-env-option">
<input
type="radio"
name="reme-python-interpreter"
checked={env.exe === selectedPath}
disabled={
env.exe === selectedPath ||
!env.ok ||
saving !== "" ||
!writable
}
onChange={() => {
selection.select(env);
setListOpen(false);
}}
/>
<strong>{`Python ${env.version || "?"}`}</strong>
<em>
{env.exe === selectedPath
? t("currentInterpreter")
: originLabel(t, env.origin)}
</em>
<InterpreterBadges
t={t}
env={env}
suggested={suggested}
/>
<code className="reme-env-option-path" title={env.exe}>
{env.exe}
</code>
</label>
))}
</div>
) : (
<p className="reme-settings-muted">{t("noMatch")}</p>
)}
{hidden > 0 ? (
<button
type="button"
className="reme-settings-reset reme-env-more"
onClick={() => setShowAll(true)}
>
{fill(t("showAllEnvs"), { count: matches.length })}
</button>
) : matches.length > ENV_PAGE ? (
<button
type="button"
className="reme-settings-reset reme-env-more"
onClick={() => setShowAll(false)}
>
{t("showFewer")}
</button>
) : null}
</>
) : null}
</div>
) : null}
{detected && detected.errors.length > 0 ? (
<details className="reme-settings-details">
<summary>{t("errorsTitle")}</summary>
<pre>{detected.errors.join("\n")}</pre>
</details>
) : null}
</div>
</section>
);
}

View file

@ -0,0 +1,300 @@
import type { StatusRpc } from "../../client/status-page.js";
import type {
ConnectionHealth,
EnvSummary,
InstallStatus,
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceStartResult,
ServiceState,
ServiceStopResult,
} from "../types.js";
/** Methods the Host exposes on the `remeEnv` wire namespace. */
export type EnvMethod =
| "python"
| "checkReme"
| "install"
| "installStatus"
| "installCancel"
| "readEnv"
| "writeEnv"
| "testLlm"
| "health"
| "serviceStatus"
| "serviceStart"
| "serviceStop"
| "serviceForget"
| "serviceLog";
/**
* Call one environment method. DSH exposes the SRC gateway with a single plain
* `input` parameter, so every call sends `{ args: { input } }`.
*/
export async function envCall(
rpc: StatusRpc,
method: EnvMethod,
input: Record<string, unknown>,
signal?: AbortSignal,
): Promise<unknown> {
const result = await rpc.call(
"/api",
`remeEnv/${method}`,
{ args: { input } },
signal,
);
if (!result.ok) throw new Error(result.error.message || result.error.code);
return result.value;
}
function record(value: unknown, what: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error(`Invalid ${what} response`);
return value as Record<string, unknown>;
}
function fail(what: string): never {
throw new Error(`Invalid ${what} response`);
}
function stringField(
body: Record<string, unknown>,
key: string,
what: string,
): string {
const value = body[key];
if (typeof value !== "string") fail(what);
return value;
}
function booleanField(
body: Record<string, unknown>,
key: string,
what: string,
): boolean {
const value = body[key];
if (typeof value !== "boolean") fail(what);
return value;
}
function optionalStringField(
body: Record<string, unknown>,
key: string,
what: string,
): void {
if (body[key] !== undefined && typeof body[key] !== "string") fail(what);
}
function optionalPidField(
body: Record<string, unknown>,
key: string,
what: string,
): void {
const value = body[key];
if (value === undefined) return;
if (typeof value !== "number" || !Number.isInteger(value) || value <= 1)
fail(what);
}
/**
* The record a refused action was about. The Host names it so the client can
* offer a recovery for that record, which is not necessarily the endpoint the
* request was made for.
*/
function optionalRecordField(
body: Record<string, unknown>,
key: string,
what: string,
): void {
const value = body[key];
if (value === undefined) return;
const entry = record(value, what);
stringField(entry, "endpoint", what);
optionalPidField(entry, "pid", what);
}
function arrayField(
body: Record<string, unknown>,
key: string,
what: string,
): unknown[] {
const value = body[key];
if (!Array.isArray(value)) fail(what);
return value;
}
/**
* Guard a detection payload. The renderers dereference `errors`, `origin` and
* the per-interpreter fields, so a Host contract drift must surface as an
* error in the section, not as a blank page.
*/
export function parseDetection(value: unknown): PythonDetection {
const what = "environment detection";
const body = record(value, what);
stringField(body, "platform", what);
stringField(body, "scannedAt", what);
stringField(body, "suggested", what);
stringField(body, "condaRoot", what);
arrayField(body, "errors", what);
for (const item of arrayField(body, "envs", what)) {
const env = record(item, what);
stringField(env, "exe", what);
stringField(env, "version", what);
booleanField(env, "ok", what);
const origin = record(env.origin, what);
const kind = stringField(origin, "kind", what);
if (
![
"conda",
"python.org",
"windows-store",
"venv",
"system",
"unknown",
].includes(kind)
)
fail(what);
if (kind === "conda") {
stringField(origin, "envName", what);
stringField(origin, "root", what);
}
if (kind === "venv") stringField(origin, "baseExe", what);
if (env.reme !== undefined) {
const reme = record(env.reme, what);
booleanField(reme, "installed", what);
stringField(reme, "version", what);
}
}
return value as PythonDetection;
}
export function parseRemeCheck(value: unknown): RemeCheck {
const what = "ReMe check";
const body = record(value, what);
booleanField(body, "installed", what);
booleanField(body, "versionOk", what);
booleanField(body, "hasCore", what);
stringField(body, "version", what);
stringField(body, "cliPath", what);
stringField(body, "error", what);
return value as unknown as RemeCheck;
}
/** `null` is the valid "no job yet" answer, so it is not an error. */
export function parseInstallStatus(value: unknown): InstallStatus | null {
if (value === null) return null;
const what = "install status";
const body = record(value, what);
stringField(body, "id", what);
stringField(body, "pythonPath", what);
stringField(body, "startedAt", what);
stringField(body, "logPath", what);
stringField(body, "logTail", what);
if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
const phase = stringField(body, "phase", what);
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
fail(what);
return value as unknown as InstallStatus;
}
export function parseEnvSummary(value: unknown): EnvSummary {
const what = ".env summary";
const body = record(value, what);
stringField(body, "workspaceDir", what);
stringField(body, "envPath", what);
booleanField(body, "exists", what);
for (const item of arrayField(body, "entries", what)) {
const entry = record(item, what);
stringField(entry, "key", what);
booleanField(entry, "secret", what);
booleanField(entry, "set", what);
stringField(entry, "masked", what);
stringField(entry, "value", what);
}
return value as unknown as EnvSummary;
}
export function parseLlmResult(value: unknown): LlmTestResult {
const what = "LLM test";
const body = record(value, what);
booleanField(body, "ok", what);
if (typeof body.status !== "number" || typeof body.latencyMs !== "number")
fail(what);
stringField(body, "error", what);
return value as unknown as LlmTestResult;
}
export function parseHealth(value: unknown): ConnectionHealth {
const what = "health";
const body = record(value, what);
booleanField(body, "ok", what);
stringField(body, "version", what);
stringField(body, "error", what);
stringField(body, "endpoint", what);
optionalStringField(body, "code", what);
return value as unknown as ConnectionHealth;
}
export function parseServiceState(value: unknown): ServiceState {
const what = "service status";
const body = record(value, what);
const phase = stringField(body, "phase", what);
if (!["running", "stopped", "conflict"].includes(phase)) fail(what);
booleanField(body, "managed", what);
stringField(body, "endpoint", what);
stringField(body, "logPath", what);
optionalPidField(body, "pid", what);
optionalStringField(body, "version", what);
optionalStringField(body, "startedAt", what);
optionalRecordField(body, "record", what);
return value as unknown as ServiceState;
}
export function parseServiceStart(value: unknown): ServiceStartResult {
const what = "service start";
const body = record(value, what);
booleanField(body, "started", what);
booleanField(body, "alreadyRunning", what);
stringField(body, "command", what);
stringField(body, "logPath", what);
stringField(body, "startedAt", what);
optionalStringField(body, "error", what);
optionalStringField(body, "code", what);
optionalStringField(body, "version", what);
optionalPidField(body, "pid", what);
optionalRecordField(body, "record", what);
return value as unknown as ServiceStartResult;
}
export function parseServiceStop(value: unknown): ServiceStopResult {
const what = "service stop";
const body = record(value, what);
booleanField(body, "stopped", what);
optionalStringField(body, "reason", what);
optionalStringField(body, "code", what);
optionalPidField(body, "pid", what);
optionalRecordField(body, "record", what);
return value as unknown as ServiceStopResult;
}
export function parseServiceForget(value: unknown): ServiceForgetResult {
const what = "service forget";
const body = record(value, what);
booleanField(body, "forgotten", what);
optionalStringField(body, "reason", what);
optionalStringField(body, "code", what);
optionalPidField(body, "pid", what);
optionalRecordField(body, "record", what);
return value as unknown as ServiceForgetResult;
}
export function parseServiceLog(value: unknown): ServiceLog {
const what = "service log";
const body = record(value, what);
stringField(body, "lines", what);
stringField(body, "logPath", what);
return value as unknown as ServiceLog;
}

View file

@ -0,0 +1,858 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import { serviceEndpointKey } from "../endpoint-key.js";
import { canHostService } from "../dependency.js";
import type {
HealthErrorCode,
RemeCheck,
ServiceRecordRef,
ServiceStartResult,
ServiceState,
} from "../types.js";
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
import { StatusBadge } from "./presentation.js";
import {
envCall,
parseHealth,
parseServiceForget,
parseServiceLog,
parseServiceStart,
parseServiceState,
parseServiceStop,
} from "./rpc.js";
/**
* How long a detached start may take to answer `/health_check`. Kept at the
* Host's start grace period, so the client never gives up while the Host still
* considers the launch in flight.
*/
const START_DEADLINE_MS = 120_000;
/** Host start errors carry a stable code; older/unknown codes fall back. */
const START_ERROR_KEYS: Record<string, EnvKey> = {
"other-endpoint-starting": "startErrorOtherEndpoint",
conflict: "startErrorConflict",
"other-endpoint-running": "startErrorOtherEndpointRunning",
"previous-not-stopped": "startErrorPreviousNotStopped",
"unverified-record": "startErrorUnverifiedRecord",
"no-python": "startErrorNoPython",
"not-installed": "startErrorNotInstalled",
"version-too-old": "startErrorVersionTooOld",
"core-missing": "startErrorCoreMissing",
"start-failed": "startErrorFailed",
};
function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
// The unverified record carries the pid it refused to touch, so the message
// can tell the user what to look for instead of a generic sentence.
if (result.code === "unverified-record" && result.pid !== undefined)
return fill(t("startErrorUnverifiedRecordPid"), { pid: result.pid });
const key =
result.code && Object.hasOwn(START_ERROR_KEYS, result.code)
? START_ERROR_KEYS[result.code]
: undefined;
return key ? t(key) : result.error || t("serviceError");
}
const STOP_REASON_KEYS: Record<string, EnvKey> = {
"no-record": "stopNoRecord",
foreign: "stopForeign",
unverified: "stopUnknownIdentity",
"not-exited": "stopNotExited",
"record-changed": "stopRecordChanged",
};
function stopReasonText(
t: EnvTranslator,
result: { code?: string; reason?: string },
): string {
const key =
result.code && Object.hasOwn(STOP_REASON_KEYS, result.code)
? STOP_REASON_KEYS[result.code]
: undefined;
return key ? t(key) : result.reason || t("serviceError");
}
/**
* Codes whose refusal leaves the record as the only way out: Start and Stop
* both refuse to act on such a record, so it can only be forgotten.
*/
const RECORD_ONLY_CODES = new Set([
"unverified-record",
"previous-not-stopped",
"unverified",
"not-exited",
]);
/**
* The recovery a refused action allows.
*
* The Host names the record it refused on: that record may belong to another
* endpoint than the one on screen, and forgetting the endpoint on screen
* would be refused as foreign. A refusal that names no record (a conflict, a
* missing interpreter) has no record-only way out. An older Host that names
* no record cannot safely offer Forget for the endpoint on screen.
*/
function recoveryFor(result: {
code?: string;
record?: ServiceRecordRef;
}): ServiceRecordRef | undefined {
if (!result.code || !RECORD_ONLY_CODES.has(result.code)) return undefined;
return result.record;
}
export interface ServiceViewState {
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
data?: ServiceState;
error?: string;
}
export interface ServiceLogView {
open: boolean;
lines: string;
logPath: string;
}
export interface ServiceControl {
state: ServiceViewState;
stopping: boolean;
/** Why the last Stop request left the service alone, if it did. */
stopError: string;
/**
* The record the Host refused to act on. It can only be forgotten, and
* forgetting targets this record, not the endpoint on screen.
*/
forgetTarget?: ServiceRecordRef;
forgetting: boolean;
start(): Promise<void>;
stop(): Promise<void>;
forget(): Promise<void>;
refresh(): void;
log: ServiceLogView;
viewLog(): Promise<void>;
dismissLog(): void;
}
/**
* The service section's asynchronous state, keyed by endpoint: one status read
* per endpoint, plus the start and stop operations for the managed instance.
*/
export function useService(
rpc: StatusRpc,
options: {
t: EnvTranslator;
endpoint: string;
/** Only a loopback endpoint can be managed, so remote ones are read-only. */
local: boolean;
pythonPath: string;
workspaceDir: string;
authToken: string;
},
): ServiceControl {
const { t, endpoint, local, pythonPath, workspaceDir, authToken } = options;
const [state, setState] = useState<ServiceViewState>({ phase: "checking" });
const [stopping, setStopping] = useState(false);
const [stopError, setStopError] = useState("");
const [forgetTarget, setForgetTarget] = useState<
ServiceRecordRef | undefined
>(undefined);
const [forgetting, setForgetting] = useState(false);
const [tick, setTick] = useState(0);
const [log, setLog] = useState<ServiceLogView>({
open: false,
lines: "",
logPath: "",
});
const startController = useRef<AbortController | undefined>(undefined);
/** True only while a start this tab requested is being polled. */
const startInFlight = useRef(false);
/** The endpoint the callbacks below belong to; stale answers are dropped. */
const endpointRef = useRef(endpoint);
/** Sequence of status answers; a slow one must not overwrite a newer phase. */
const statusSeq = useRef(0);
const applyState = useCallback((next: ServiceViewState) => {
// An answer that settles a start is newer than any status read that was
// already in flight, so that read is discarded when it lands.
statusSeq.current += 1;
setState(next);
}, []);
// A new endpoint has no known phase yet; keeping the previous endpoint's
// "running + old pid/version" on screen would mislabel the new one. A start
// poll for the old endpoint must stop before it can paint that old data.
useEffect(() => {
endpointRef.current = endpoint;
startInFlight.current = false;
startController.current?.abort();
// Answers requested for the old endpoint are no longer relevant.
statusSeq.current += 1;
// Resetting the view when the endpoint prop changes is intentional; the
// hook has no key, so this is the reset path.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState({ phase: "checking" });
setStopError("");
setStopping(false);
setForgetTarget(undefined);
setForgetting(false);
}, [endpoint]);
useEffect(() => () => startController.current?.abort(), []);
useEffect(() => {
if (!local || !endpoint) return;
const controller = new AbortController();
const request = statusSeq.current + 1;
statusSeq.current = request;
void (async () => {
try {
const next = parseServiceState(
await envCall(
rpc,
"serviceStatus",
{ endpoint, authToken },
controller.signal,
),
);
if (controller.signal.aborted || request !== statusSeq.current) return;
// The record is gone (dead pid cleared by the Host, or just forgotten),
// so there is nothing left to forget. This status read only covers the
// endpoint on screen, so a record for another endpoint stays.
if (next.phase === "stopped" && next.pid === undefined)
setForgetTarget((current) =>
current &&
serviceEndpointKey(current.endpoint) ===
serviceEndpointKey(endpoint)
? undefined
: current,
);
// A record the Host could not verify is the only way out of a port it
// cannot clear, and the conflict card offers no other action: the
// status names it, so the recovery is offered without waiting for a
// refused start the card does not allow.
const unverified = next.record;
if (unverified)
setForgetTarget((current) =>
current &&
current.endpoint === unverified.endpoint &&
current.pid === unverified.pid
? current
: {
endpoint: unverified.endpoint,
...(unverified.pid === undefined
? {}
: { pid: unverified.pid }),
},
);
// A start this tab requested maps "stopped with a live pid" (the Host's
// "launch still booting" signal) to `starting`; a plain status read
// keeps the Host's phase, so a leftover process never locks the card.
setState({
phase:
next.phase === "stopped" &&
next.pid !== undefined &&
startInFlight.current
? "starting"
: next.phase,
data: next,
});
} catch (error) {
if (controller.signal.aborted || request !== statusSeq.current) return;
setState({ phase: "error", error: safeError(error) });
}
})();
return () => controller.abort();
}, [rpc, endpoint, local, authToken, tick]);
const refresh = useCallback(() => setTick((current) => current + 1), []);
const start = useCallback(async () => {
if (!pythonPath || state.phase === "starting") return;
startController.current?.abort();
const controller = new AbortController();
startController.current = controller;
startInFlight.current = true;
// A status read requested before this click is older than the start.
statusSeq.current += 1;
setState({ phase: "starting" });
setStopError("");
setLog({ open: false, lines: "", logPath: "" });
try {
const started = parseServiceStart(
await envCall(
rpc,
"serviceStart",
{ endpoint, pythonPath, workspaceDir, authToken },
controller.signal,
),
);
if (controller.signal.aborted) return;
if (started.error) {
if (started.code === "conflict") {
// The Host refused because the port answered. Showing that as a
// conflict card (with Stop when it is ours) matches what the poll
// below would report, instead of an error whose Start just repeats.
try {
const status = parseServiceState(
await envCall(
rpc,
"serviceStatus",
{ endpoint, authToken },
controller.signal,
),
);
if (controller.signal.aborted) return;
if (status.phase === "conflict") {
applyState({ phase: "conflict", data: status });
return;
}
} catch {
// A switch of endpoint aborts this call; its error belongs to the
// old endpoint and must not be painted (or bump the sequence) on
// the new card.
if (controller.signal.aborted) return;
// Otherwise fall back to the reported start error.
}
}
// The Host refused because the record describes a live process it
// cannot stop or verify. Neither Start nor Stop can act on it, so offer
// the record-only escape hatch, aimed at the record the Host named.
// A refusal that names no record leaves an existing offer alone: the
// record it describes is still there.
const recovery = recoveryFor(started);
if (recovery) setForgetTarget(recovery);
applyState({ phase: "error", error: startErrorText(t, started) });
return;
}
const deadline = Date.now() + START_DEADLINE_MS;
let ownedConflict: ServiceState | undefined;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (controller.signal.aborted) return;
if (Date.now() > deadline) {
// A service that holds the port but never turns healthy cannot be
// recovered by waiting; an *owned* one is still stoppable, so keep
// that way out instead of a dead-end timeout error.
applyState(
ownedConflict
? { phase: "conflict", data: ownedConflict }
: { phase: "error", error: t("startTimeout") },
);
return;
}
try {
const status = parseServiceState(
await envCall(
rpc,
"serviceStatus",
{ endpoint, authToken },
controller.signal,
),
);
if (controller.signal.aborted) return;
if (status.phase === "running") {
setForgetTarget(undefined);
applyState({ phase: "running", data: status });
return;
}
if (status.phase === "conflict") {
if (status.managed) {
// Our own launch can hold the port before `/health_check`
// passes. Keep polling instead of offering Stop too early; if it
// never becomes healthy the deadline shows the conflict card.
ownedConflict = status;
continue;
}
applyState({ phase: "conflict", data: status });
return;
}
if (status.phase === "stopped" && status.pid === undefined) {
// The Host already dropped the start marker: the launch died before
// it ever listened, so waiting out the 120 s deadline is wrong.
applyState({ phase: "error", error: t("startFailedStopped") });
return;
}
// stopped with a live pid: the process is still booting. Carry the
// pid so a launch that never becomes healthy can still be stopped.
if (status.pid !== undefined)
setState((current) =>
current.phase === "starting"
? { phase: "starting", data: status }
: current,
);
} catch {
// Keep polling: a starting service answers once it is listening.
}
}
} catch (error) {
if (controller.signal.aborted) return;
applyState({ phase: "error", error: safeError(error) });
} finally {
if (startController.current === controller) {
startController.current = undefined;
startInFlight.current = false;
}
}
}, [
applyState,
authToken,
endpoint,
pythonPath,
rpc,
state.phase,
t,
workspaceDir,
]);
const stop = useCallback(async () => {
if (stopping) return;
const target = endpoint;
// A Stop during a start must cancel that poll, or its next tick would
// repaint the launch the user just cancelled.
startController.current?.abort();
startInFlight.current = false;
setStopping(true);
try {
const result = parseServiceStop(
await envCall(rpc, "serviceStop", { endpoint: target }),
);
if (endpointRef.current !== target) return;
setStopError(result.stopped ? "" : stopReasonText(t, result));
// A record the Host refuses to stop (unverifiable, or a process that
// survived the force-kill) cannot be cleared by either button: offer the
// record-only way out instead of leaving two actions that both fail.
const recovery = recoveryFor(result);
if (recovery) setForgetTarget(recovery);
// A stopped service has no record left; a refusal that names none (a
// foreign record, a missing one) leaves an existing offer alone.
else if (result.stopped || result.code === "no-record")
setForgetTarget(undefined);
refresh();
} catch (error) {
if (endpointRef.current !== target) return;
applyState({ phase: "error", error: safeError(error) });
} finally {
// A result for the previous endpoint must not disable the new card; that
// endpoint's effect already reset the flag.
if (endpointRef.current === target) setStopping(false);
}
}, [applyState, endpoint, refresh, rpc, stopping, t]);
const forget = useCallback(async () => {
if (forgetting || !forgetTarget) return;
const card = endpoint;
// The record is named by the Host, not by the endpoint on screen, so the
// request carries the record's own endpoint and the pid it described.
const target = forgetTarget;
setForgetting(true);
try {
const result = parseServiceForget(
await envCall(rpc, "serviceForget", {
endpoint: target.endpoint,
...(target.pid === undefined ? {} : { expectPid: target.pid }),
}),
);
if (endpointRef.current !== card) return;
if (result.forgotten) {
setForgetTarget(undefined);
setStopError("");
// Drop the stale pid immediately; the refresh confirms it.
applyState({ phase: "checking" });
refresh();
} else {
setStopError(stopReasonText(t, result));
if (result.code === "no-record") {
// The record is already gone: there is nothing left to forget, so the
// button must not offer an action that can never succeed.
setForgetTarget(undefined);
} else if (result.code === "record-changed") {
// The record the user confirmed is not the one that is there now.
// The offer is dropped instead of being re-aimed at a record the
// user never saw: the next refusal names the current one.
setForgetTarget(undefined);
}
}
} catch (error) {
if (endpointRef.current !== card) return;
applyState({ phase: "error", error: safeError(error) });
} finally {
if (endpointRef.current === card) setForgetting(false);
}
}, [applyState, endpoint, forgetTarget, forgetting, refresh, rpc, t]);
const viewLog = useCallback(async () => {
try {
const value = parseServiceLog(
await envCall(rpc, "serviceLog", { workspaceDir }),
);
setLog({ open: true, lines: value.lines, logPath: value.logPath });
} catch {
// Log viewing is diagnostic only.
}
}, [rpc, workspaceDir]);
const dismissLog = useCallback(
() => setLog({ open: false, lines: "", logPath: "" }),
[],
);
return {
state,
stopping,
stopError,
forgetTarget,
forgetting,
start,
stop,
forget,
refresh,
log,
viewLog,
dismissLog,
};
}
export interface ConnectionState {
phase: "loading" | "ok" | "fail";
endpoint: string;
version?: string;
error?: string;
/** Stable Host reason, localized by the hero instead of showing `error`. */
code?: HealthErrorCode;
}
/**
* Health of a ReMe service that this plugin cannot manage locally (a remote
* URL), so the hero still reports whether the configured endpoint answers.
*/
export function useHealth(options: {
rpc: StatusRpc;
endpoint: string;
enabled: boolean;
tick: number;
authToken: string;
}): ConnectionState {
const { rpc, endpoint, enabled, tick, authToken } = options;
const [health, setHealth] = useState<ConnectionState>({
phase: "loading",
endpoint: "",
});
useEffect(() => {
if (!enabled || !endpoint) return;
const controller = new AbortController();
void (async () => {
try {
const result = parseHealth(
await envCall(
rpc,
"health",
{ endpoint, authToken },
controller.signal,
),
);
if (controller.signal.aborted) return;
setHealth(
result.ok
? { phase: "ok", endpoint, version: result.version }
: {
phase: "fail",
endpoint,
error: result.error,
...(result.code === undefined ? {} : { code: result.code }),
},
);
} catch (error) {
if (!controller.signal.aborted)
setHealth({ phase: "fail", endpoint, error: safeError(error) });
}
})();
return () => controller.abort();
}, [authToken, endpoint, enabled, rpc, tick]);
return health;
}
/** Step 4: the local ReMe service that DSH talks to. */
export function ServiceSection({
t,
endpoint,
pythonPath,
control,
remeCheck,
autoStart,
writable,
current,
onAutoStart,
}: {
t: EnvTranslator;
endpoint: string;
pythonPath: string;
control: ServiceControl;
/** What the selected interpreter has; the shared rule decides a start. */
remeCheck: RemeCheck | undefined;
autoStart: boolean;
writable: boolean;
/** False while the state on screen belongs to another endpoint. */
current: boolean;
onAutoStart(checked: boolean): void;
}): JSX.Element {
const { state, stopping, stopError, forgetTarget, forgetting, log } = control;
const [confirming, setConfirming] = useState(false);
const [forgetConfirm, setForgetConfirm] = useState(false);
// The confirmation belongs to one phase and endpoint: keeping it open across
// a transition would re-show "Stop?" on top of a live service later.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setConfirming(false);
setForgetConfirm(false);
}, [endpoint, state.phase, state.data?.pid, forgetTarget]);
// A conflict can still be our own unhealthy service (booting, wrong token);
// offer the same Stop affordance instead of leaving the tab without a way
// out other than editing the record by hand. `stopped`/`starting` with a
// live pid is a recorded launch that never became healthy: Stop is the only
// way to clear it, so it must be offered there too.
const canStop =
current &&
(state.phase === "running" ||
(state.phase === "conflict" && state.data?.managed === true) ||
((state.phase === "stopped" || state.phase === "starting") &&
state.data?.pid !== undefined));
// Only a pid this plugin recorded can be stopped; a foreign running service
// has no managed flag and keeps the button disabled.
const canSignal =
state.data?.managed === true ||
((state.phase === "stopped" || state.phase === "starting") &&
state.data?.pid !== undefined);
// A stale record may be a recycled pid that now belongs to a foreign
// process. Stop then refuses, so Start must stay available: the Host stops a
// provably owned previous launch, discards a foreign record, and reports an
// unverifiable one. Hiding Start would leave that card without a way out.
const canRestart = state.phase === "stopped" && state.data?.pid !== undefined;
// The record can only be forgotten when the Host named one: it is the
// record-only way out of a process the Host refuses to signal.
const canForget = current && forgetTarget !== undefined;
// The confirmation names the record, so the user sees what they are about
// to delete and not only where it lives.
const forgetText = forgetTarget
? `${fill(t("forgetConfirmRecord"), { endpoint: forgetTarget.endpoint })}${
forgetTarget.pid === undefined
? ""
: ` ${fill(t("forgetConfirmPid"), { pid: forgetTarget.pid })}`
}`
: "";
return (
<section className="reme-env-card">
<header>
<span className="reme-env-step" aria-hidden="true">
4
</span>
<div>
<h4>{t("controlTitle")}</h4>
<p>{t("serviceHint")}</p>
</div>
<aside>
{!current ||
state.phase === "checking" ||
state.phase === "starting" ? (
<StatusBadge tone="progress">
{t(
!current
? "checking"
: state.phase === "starting"
? "serviceBadgeStarting"
: "checking",
)}
</StatusBadge>
) : state.phase === "running" ? (
<StatusBadge tone="ok">
{state.data?.version
? `ReMe ${state.data.version}`
: t("serviceRunning")}
</StatusBadge>
) : state.phase === "conflict" ? (
<StatusBadge tone="progress">
{t(
state.data?.managed
? "serviceBadgeUnhealthy"
: "serviceBadgeConflict",
)}
</StatusBadge>
) : state.phase === "stopped" && state.data?.pid !== undefined ? (
<StatusBadge tone="progress">{t("serviceBadgeStale")}</StatusBadge>
) : (
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
)}
</aside>
</header>
<div className="reme-env-card-body">
<div className="reme-env-factrow">
<span className="reme-env-field-label">{t("endpointLabel")}</span>
<code className="reme-env-path" title={endpoint}>
{endpoint}
</code>
<button
type="button"
className="reme-settings-button"
onClick={control.refresh}
>
{t("recheck")}
</button>
</div>
{current &&
(state.phase === "running" || state.phase === "conflict") ? (
<p className="reme-settings-muted">
{state.data?.managed
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
: state.phase === "running"
? t("foreignNote")
: null}
</p>
) : null}
{current && state.phase === "conflict" ? (
<p className="reme-settings-muted">
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
</p>
) : null}
{current &&
state.phase === "stopped" &&
state.data?.pid !== undefined ? (
<p className="reme-settings-muted">{t("staleNote")}</p>
) : null}
{current && state.phase === "error" && state.error ? (
<p className="reme-settings-error">{state.error}</p>
) : null}
{stopError ? (
<p className="reme-settings-error" role="alert">
{stopError}
</p>
) : null}
{current && canForget ? (
<p className="reme-settings-muted">{t("forgetHint")}</p>
) : null}
<div className="reme-env-cta">
{canStop ? (
confirming ? (
<div className="reme-env-confirm">
<p>{t("stopConfirm")}</p>
<button
type="button"
className="reme-settings-save"
disabled={stopping}
onClick={() => {
setConfirming(false);
void control.stop();
}}
>
{t("stopService")}
</button>
<button
type="button"
className="reme-settings-discard"
onClick={() => setConfirming(false)}
>
{t("cancel")}
</button>
</div>
) : (
<button
type="button"
className="reme-settings-button"
disabled={stopping || !canSignal}
onClick={() => setConfirming(true)}
>
{t("stopService")}
</button>
)
) : null}
{!canStop || canRestart ? (
<button
type="button"
className="reme-settings-save"
disabled={
!current ||
state.phase === "starting" ||
state.phase === "conflict" ||
!canHostService(remeCheck) ||
!pythonPath
}
onClick={() => void control.start()}
>
{t("startService")}
</button>
) : null}
{current && canForget ? (
forgetConfirm ? (
<div className="reme-env-confirm">
<p>{forgetText}</p>
<button
type="button"
className="reme-settings-discard"
disabled={forgetting}
onClick={() => {
setForgetConfirm(false);
void control.forget();
}}
>
{t("forgetRecord")}
</button>
<button
type="button"
className="reme-settings-button"
onClick={() => setForgetConfirm(false)}
>
{t("cancel")}
</button>
</div>
) : (
<button
type="button"
className="reme-settings-discard"
disabled={forgetting}
onClick={() => setForgetConfirm(true)}
>
{t("forgetRecord")}
</button>
)
) : null}
<button
type="button"
className="reme-settings-button"
onClick={() => void control.viewLog()}
>
{t("viewLogs")}
</button>
<label className="reme-settings-check reme-env-autostart">
<input
type="checkbox"
checked={autoStart}
disabled={!writable}
onChange={(event) => {
if (!writable) return;
onAutoStart(event.target.checked);
}}
/>
<span>{t("autoStartLabel")}</span>
</label>
</div>
{log.open ? (
<div className="reme-env-result">
<p className="reme-settings-muted">
{`${t("envPath")}: ${log.logPath}`}
</p>
<pre className="reme-env-log">{log.lines || t("logEmpty")}</pre>
<button
type="button"
className="reme-settings-button"
onClick={control.dismissLog}
>
{t("dismiss")}
</button>
</div>
) : null}
</div>
</section>
);
}

View file

@ -0,0 +1,84 @@
/** Styles for the Environment tab: the hero, the step cards, and the .env form. */
export const environmentStyles = `
.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
.reme-env-hero-copy{min-width:0}
.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
.reme-env-hero-error,.reme-env-notice{margin:0}
.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
.reme-env-card.collapsed>header{padding-bottom:14px}
.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
.reme-env-card>header>div{min-width:0;flex:1}
.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
.reme-env-note{margin:0}
.reme-env-picker{display:flex;flex-direction:column;gap:9px}
.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.reme-env-picker-bar .reme-settings-muted{margin:0}
.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
.reme-env-factrow{display:flex;align-items:center;gap:9px}
.reme-env-factrow .reme-env-path{flex:1}
.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
.reme-env-metric .reme-status-badge i{margin-top:5px}
.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
.reme-env-progress{display:flex;flex-direction:column;gap:8px}
.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
.reme-env-result p{margin:0}
.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
.reme-env-inline{display:flex;align-items:center;gap:8px}
.reme-env-inline input{flex:1;min-width:0}
.reme-env-config{padding:2px 0 0}
.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
.reme-env .reme-settings-details{margin:0}
.reme-env .reme-settings-details pre{margin:8px 0 0}
@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
`;

View file

@ -0,0 +1,343 @@
import { useCallback, useState, useSyncExternalStore } from "react";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import { serviceEndpointKey } from "../endpoint-key.js";
import { isLocalEndpoint } from "../endpoint-rules.js";
import { canHostService } from "../dependency.js";
import type { HealthErrorCode, RemeCheck } from "../types.js";
import type { ReMeSettings } from "../../types.js";
import { DependencySection, useDependency } from "./dependency.js";
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
import { StatusBadge, type Badge } from "./presentation.js";
import { PythonSection, usePython, type PythonSelection } from "./python.js";
import {
ServiceSection,
useHealth,
useService,
type ConnectionState,
} from "./service.js";
import { WorkspaceSection } from "./workspace.js";
export { envEn, envZh } from "./locales.js";
export type { EnvTranslator } from "./locales.js";
export interface ReMeEnvironmentTabProps {
scope: ConfigForm<ReMeSettings>;
rpc: StatusRpc;
t: EnvTranslator;
}
function connectionBadge(
t: EnvTranslator,
connection: ConnectionState,
current: boolean,
): Badge {
const label = (detail: string) => `${t("heroService")} · ${detail}`;
if (!current || connection.phase === "loading")
return { tone: "", text: label(t("checking")) };
if (connection.phase === "ok")
return {
tone: "ok",
text: label(
connection.version ? `ReMe ${connection.version}` : t("connected"),
),
};
return { tone: "bad", text: label(t("disconnected")) };
}
/** Host health failures carry a stable code; older Hosts fall back to `error`. */
const HEALTH_ERROR_KEYS: Partial<Record<HealthErrorCode, EnvKey>> = {
"http-status": "healthErrorHttp",
"not-reme": "healthErrorNotReme",
"reme-error": "healthErrorReme",
unreachable: "healthErrorUnreachable",
};
function healthErrorText(
t: EnvTranslator,
connection: ConnectionState,
): string {
if (connection.phase !== "fail") return "";
const key =
connection.code && Object.hasOwn(HEALTH_ERROR_KEYS, connection.code)
? HEALTH_ERROR_KEYS[connection.code]
: undefined;
return key ? t(key) : connection.error ?? "";
}
function interpreterBadge(t: EnvTranslator, selection: PythonSelection): Badge {
const env = selection.selectedEnv;
if (!env)
return {
tone: "",
text: `${t("heroPython")} · ${t(
selection.detection.phase === "error" ? "noneDetected" : "noneSelected",
)}`,
};
return {
tone: env.ok ? "ok" : "progress",
text: `${t("heroPython")} ${env.version || "?"}`,
};
}
function remeSummaryBadge(
t: EnvTranslator,
check: RemeCheck | undefined,
checking: boolean,
): Badge {
if (!check)
return {
tone: "",
text: `${t("heroReme")} · ${t(checking ? "checking" : "noneDetected")}`,
};
if (!check.installed)
return {
tone: "progress",
text: `${t("heroReme")} · ${t("notInstalled")}`,
};
return {
tone: canHostService(check) ? "ok" : "progress",
text: `${t("heroReme")} ${check.version}`,
};
}
/**
* The Environment tab: one hero with the local-runtime summary, then the four
* steps. Each step owns its own asynchronous state; this component only reads
* the settings document and lays the sections out.
*/
export function ReMeEnvironmentTab({
scope,
rpc,
t,
}: ReMeEnvironmentTabProps): JSX.Element | null {
const subscribe = useCallback(
(listener: () => void) => scope.subscribe(listener),
[scope],
);
const getSnapshot = useCallback(() => scope.getSnapshot(), [scope]);
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
const value = snapshot.value;
const endpoint = value?.endpoint ?? "";
const local = isLocalEndpoint(endpoint);
const workspaceDir = value?.remeWorkspaceDir ?? "";
const authToken = value?.authToken ?? "";
const [heroTick, setHeroTick] = useState(0);
// Settings writes are invisible to the sections otherwise: a rejected
// revision or a transport error would silently roll the control back. The
// message carries its endpoint so a change of URL drops it without an effect.
const [settingsError, setSettingsError] = useState<{
endpoint: string;
message: string;
}>({ endpoint: "", message: "" });
const writeSetting = useCallback(
async (field: string, next: unknown): Promise<boolean> => {
if (!snapshot.writable) return false;
try {
const applied = await scope.mutate(
[{ op: "set", path: [field], value: next }],
snapshot.revision,
);
if (applied === false) {
setSettingsError({ endpoint, message: t("settingsWriteFailed") });
return false;
}
setSettingsError({ endpoint, message: "" });
return true;
} catch (error) {
setSettingsError({ endpoint, message: safeError(error) });
return false;
}
},
[endpoint, scope, snapshot.revision, snapshot.writable, t],
);
const settingsErrorText =
settingsError.endpoint === endpoint ? settingsError.message : "";
const python = usePython(rpc, {
pythonPath: value?.pythonPath ?? "",
writable: snapshot.writable,
enabled: local,
onSelect: (exe) => writeSetting("pythonPath", exe),
});
const dependency = useDependency(rpc, {
pythonPath: python.selectedPath,
enabled: local,
onInstalled: () => {
python.refresh();
},
});
const service = useService(rpc, {
t,
endpoint,
local,
pythonPath: python.selectedPath,
workspaceDir,
authToken,
});
// One health source per endpoint: the service probe already covers a local
// endpoint, so only a remote URL needs its own check. Both run on the Host,
// so the browser cannot disagree with it about what "local" means.
const remote = useHealth({
rpc,
endpoint,
enabled: !local,
tick: heroTick,
authToken,
});
// The Host names the endpoint its answer belongs to; comparing that with the
// settings endpoint is what makes a stale answer fail the `current` test.
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
const serviceCurrent =
serviceEndpointKey(serviceEndpoint) === serviceEndpointKey(endpoint);
const connection: ConnectionState = local
? service.state.phase === "running"
? {
phase: "ok",
endpoint: serviceEndpoint,
version: service.state.data?.version ?? "",
}
: service.state.phase === "checking" || service.state.phase === "starting"
? { phase: "loading", endpoint: serviceEndpoint }
: {
phase: "fail",
endpoint: serviceEndpoint,
error: service.state.error,
}
: remote;
if (snapshot.status === "unavailable") return null;
const current =
connection.phase !== "loading" &&
serviceEndpointKey(connection.endpoint) === serviceEndpointKey(endpoint);
const connectionError = healthErrorText(t, connection);
const ready =
current &&
connection.phase === "ok" &&
(!local || canHostService(dependency.checkData));
const scannedAt = python.detection.data?.scannedAt
? Date.parse(python.detection.data.scannedAt)
: Number.NaN;
const scannedTime = Number.isFinite(scannedAt)
? new Date(scannedAt).toLocaleTimeString()
: "";
const { tone: healthTone, text: healthText } = connectionBadge(
t,
connection,
current,
);
const { tone: pythonTone, text: pythonText } = interpreterBadge(t, python);
const { tone: remeTone, text: remeText } = remeSummaryBadge(
t,
dependency.checkData,
dependency.checking,
);
return (
<div className="reme-env">
<section className="reme-env-hero">
<div className="reme-env-hero-brand">
<span className="reme-env-hero-mark" aria-hidden="true">
RE
</span>
<div className="reme-env-hero-copy">
<span className="reme-env-eyebrow">{t("heroEyebrow")}</span>
<h3>{t(ready ? "heroReady" : "heroSetup")}</h3>
<p className="reme-env-hero-sub" title={endpoint}>
{endpoint}
</p>
</div>
</div>
<div className="reme-env-hero-chips">
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
{local ? (
<>
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
</>
) : null}
</div>
<div className="reme-env-hero-actions">
<button
type="button"
className="reme-settings-button"
onClick={() => {
setHeroTick((tick) => tick + 1);
service.refresh();
}}
>
{t("retest")}
</button>
{local ? (
<button
type="button"
className="reme-settings-button"
onClick={python.refresh}
>
{t("refresh")}
</button>
) : null}
{scannedTime ? (
<span className="reme-env-hero-meta">
{fill(t("scannedAt"), { time: scannedTime })}
</span>
) : null}
</div>
</section>
{current && connectionError ? (
<p className="reme-settings-error reme-env-hero-error">
{connectionError}
</p>
) : null}
{settingsErrorText ? (
<p className="reme-settings-error reme-env-hero-error" role="alert">
{settingsErrorText}
</p>
) : null}
{!local ? (
<p className="reme-settings-muted reme-env-notice">
{t("remoteNotice")}
</p>
) : (
<>
<PythonSection
t={t}
selection={python}
writable={snapshot.writable}
/>
<DependencySection
t={t}
pythonPath={python.selectedPath}
dependency={dependency}
/>
<WorkspaceSection
key={workspaceDir}
t={t}
rpc={rpc}
workspaceDir={workspaceDir}
pythonPath={python.selectedPath}
writable={snapshot.writable}
onWorkspaceDir={(dir) => writeSetting("remeWorkspaceDir", dir)}
/>
<ServiceSection
t={t}
endpoint={endpoint}
pythonPath={python.selectedPath}
control={service}
remeCheck={dependency.checkData}
autoStart={value?.autoStart === true}
writable={snapshot.writable}
current={serviceCurrent}
onAutoStart={(checked) => {
void writeSetting("autoStart", checked);
}}
/>
</>
)}
</div>
);
}

View file

@ -0,0 +1,689 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import {
ENV_FIELDS,
envUpdates,
MANAGED_KEYS,
storedState,
} from "../env-fields.js";
import { llmBackendOf } from "../llm-backend.js";
import type { EnvEntry, EnvSummary, LlmTestResult } from "../types.js";
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
import { EnvField } from "./presentation.js";
import { envCall, parseEnvSummary, parseLlmResult } from "./rpc.js";
const LLM_BACKENDS = [
"openai",
"dashscope",
"deepseek",
"anthropic",
"gemini",
"moonshot",
"ollama",
"xai",
] as const;
const EMBEDDING_BACKENDS = [
"openai",
"dashscope",
"dashscope_multimodal",
"gemini",
"ollama",
] as const;
interface EnvFieldSpec {
key: string;
label: EnvKey;
kind: "select" | "text" | "secret";
options?: readonly string[];
placeholder?: string;
hint?: EnvKey;
/** The value a key that must not stay empty is written with. */
disabledValue?: string;
}
interface EnvGroup {
title: EnvKey;
fields: EnvFieldSpec[];
}
/**
* The wording of one managed key.
*
* Only the copy lives here: the key list, the secret flags and the update
* rules come from the shared metadata, so the form and the Host cannot drift
* apart about the same file. The group a key belongs to is named here, once.
*/
interface EnvFieldCopy {
group: EnvKey;
label: EnvKey;
options?: readonly string[];
placeholder?: string;
hint?: EnvKey;
}
const ENV_FIELD_COPY: Record<string, EnvFieldCopy> = {
LLM_BACKEND: {
group: "llmGroup",
label: "backendLabel",
options: LLM_BACKENDS,
},
LLM_MODEL_NAME: {
group: "llmGroup",
label: "modelLabel",
placeholder: "qwen3.7-plus",
},
LLM_API_KEY: { group: "llmGroup", label: "apiKeyLabel" },
LLM_BASE_URL: {
group: "llmGroup",
label: "baseUrlLabel",
placeholder: "https://…/v1",
},
EMBEDDING_BACKEND: {
group: "embeddingGroup",
label: "backendLabel",
options: EMBEDDING_BACKENDS,
},
EMBEDDING_MODEL_NAME: {
group: "embeddingGroup",
label: "modelLabel",
placeholder: "text-embedding-v4",
},
EMBEDDING_API_KEY: { group: "embeddingGroup", label: "apiKeyLabel" },
EMBEDDING_BASE_URL: {
group: "embeddingGroup",
label: "baseUrlLabel",
placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
hint: "embeddingHint",
},
};
/** The groups the form shows, in order. */
const ENV_GROUP_TITLES: EnvKey[] = ["llmGroup", "embeddingGroup"];
/**
* The `.env` parameters this tab renders, derived from the shared metadata.
*
* The shared list owns which keys exist, in which order and which of them are
* secrets — and therefore what an empty draft means; the wording comes from
* `ENV_FIELD_COPY`. A managed key without a copy would be missing from the
* form, which `tests/env-form.test.mjs` refuses.
*/
export const ENV_GROUPS: EnvGroup[] = ENV_GROUP_TITLES.map((title) => ({
title,
fields: ENV_FIELDS.flatMap((meta): EnvFieldSpec[] => {
const copy = ENV_FIELD_COPY[meta.key];
if (!copy || copy.group !== title) return [];
// A secret is a password field; a key with options is a select.
const kind: EnvFieldSpec["kind"] = meta.secret
? "secret"
: copy.options
? "select"
: "text";
return [
{
key: meta.key,
label: copy.label,
kind,
options: copy.options,
placeholder: copy.placeholder,
hint: copy.hint,
disabledValue: meta.disabledValue,
},
];
}),
}));
/**
* Which managed keys the file already holds a value for.
*
* The tab reads this from the masked projection: a secret's own value never
* rides a response, but a secret that is set is still masked to a non-empty
* tail.
*
* A key the Host did not report counts as present: the placeholder that keeps
* an unset key non-empty must never overwrite a credential the summary did
* not mention.
*/
function storedValues(data: EnvSummary): Record<string, boolean> {
const masked: Record<string, string> = {};
for (const entry of data.entries) masked[entry.key] = entry.masked;
const stored = storedState(masked);
for (const key of MANAGED_KEYS) if (!(key in masked)) stored[key] = true;
return stored;
}
/**
* The draft entries a save did not resolve.
*
* The submission covered the keys the user touched: a value that travelled in
* the payload, or a blank secret that means "keep the stored credential". An
* entry whose value is still the submitted one is therefore resolved by the
* save, while a value typed after the submission stays in the draft.
*/
function remainingEdits(
values: Readonly<Record<string, string>>,
submitted: Readonly<Record<string, string>>,
): Record<string, string> {
const remaining: Record<string, string> = {};
for (const [key, value] of Object.entries(values)) {
const sent = submitted[key];
if (sent === undefined || sent !== value) remaining[key] = value;
}
return remaining;
}
interface EnvState {
phase: "loading" | "ready" | "error";
data?: EnvSummary;
error?: string;
}
interface SaveResult {
kind: "" | "saved" | "failed";
message?: string;
/**
* True when the save left newer edits in the draft. The saved message then
* has to say so: the form is not clean.
*/
pending?: boolean;
}
interface LlmTestState {
phase: "idle" | "running" | "ok" | "fail";
result?: LlmTestResult;
}
export interface WorkspaceState {
env: EnvState;
workspaceInput: string | undefined;
workspaceDraft: string;
editWorkspace(next: string): void;
saveWorkspace(): Promise<void>;
value(key: string): string;
entry(key: string): EnvEntry | undefined;
edit(key: string, next: string): void;
saving: boolean;
result: SaveResult;
save(): Promise<void>;
llmTest: LlmTestState;
testLlm(pythonPath: string): Promise<void>;
}
interface WorkspaceOptions {
workspaceDir: string;
writable: boolean;
onWorkspaceDir(dir: string): Promise<unknown>;
}
/**
* The workspace section's asynchronous state: one `.env` read, one draft, one
* save result and one LLM probe, all belonging to the workspace directory the
* section was mounted for.
*
* The section is mounted with the workspace directory as its React key, so a
* workspace switch disposes this state (and aborts its probe) instead of
* invalidating each piece field by field.
*/
function useWorkspace(
rpc: StatusRpc,
options: WorkspaceOptions,
): WorkspaceState {
const { workspaceDir, writable, onWorkspaceDir } = options;
const [env, setEnv] = useState<EnvState>({ phase: "loading" });
const [draft, setDraft] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<SaveResult>({ kind: "" });
const [llmTest, setLlmTest] = useState<LlmTestState>({ phase: "idle" });
const [tick, setTick] = useState(0);
const [workspaceInput, setWorkspaceInput] = useState<string | undefined>();
const llmController = useRef<AbortController | undefined>(undefined);
// A mirror of the draft. A save resolves against the draft as it is at that
// moment, so the answer cannot depend on a React state updater that runs
// later — and the draft may have moved on while the save was in flight.
const draftRef = useRef<Record<string, string>>({});
// False once the section unmounted, so a save that resolves afterwards does
// not write into a disposed component.
const alive = useRef(true);
/** Set the draft and keep the mirror in step with it. */
const applyDraft = useCallback((next: Record<string, string>) => {
draftRef.current = next;
setDraft(next);
}, []);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
// A probe for this workspace must not outlive its section.
llmController.current?.abort();
};
}, []);
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const value = await envCall(
rpc,
"readEnv",
{ workspaceDir },
controller.signal,
);
if (controller.signal.aborted) return;
setEnv({ phase: "ready", data: parseEnvSummary(value) });
} catch (error) {
if (!controller.signal.aborted)
setEnv({ phase: "error", error: safeError(error) });
}
})();
return () => controller.abort();
}, [rpc, workspaceDir, tick]);
const entry = useCallback(
(key: string) => env.data?.entries.find((item) => item.key === key),
[env.data],
);
const value = useCallback(
(key: string) => {
const typed = draft[key];
if (typed !== undefined) return typed;
const stored = entry(key);
return stored?.secret ? "" : stored?.value ?? "";
},
[draft, entry],
);
const edit = useCallback(
(key: string, next: string) => {
// The mirror is the freshest copy of the draft, so two edits in one
// tick cannot overwrite each other.
applyDraft({ ...draftRef.current, [key]: next });
setResult({ kind: "" });
},
[applyDraft],
);
const save = useCallback(async () => {
if (!env.data || saving) return;
// The submission is the draft as it stands now, not as it stood when the
// button was rendered.
const pending = draftRef.current;
// Only touched keys travel: an untouched key keeps its stored value, an
// empty touched value is an explicit clear, and a blank secret keeps the
// stored credential. The shared metadata owns those rules.
const payload = envUpdates(pending, storedValues(env.data));
setSaving(true);
setResult({ kind: "" });
try {
const summary = parseEnvSummary(
await envCall(rpc, "writeEnv", { workspaceDir, values: payload }),
);
if (!alive.current) return;
setEnv({ phase: "ready", data: summary });
// The save resolves the edits it carried; a value typed while it was in
// flight stays in the draft.
const remaining = remainingEdits(draftRef.current, pending);
applyDraft(remaining);
setResult({
kind: "saved",
pending: Object.keys(remaining).length > 0,
});
} catch (error) {
if (!alive.current) return;
setResult({ kind: "failed", message: safeError(error) });
} finally {
if (alive.current) setSaving(false);
}
}, [applyDraft, env.data, rpc, saving, workspaceDir]);
const testLlm = useCallback(
async (pythonPath: string) => {
if (!pythonPath || llmTest.phase === "running") return;
llmController.current?.abort();
const controller = new AbortController();
llmController.current = controller;
setLlmTest({ phase: "running" });
try {
const data = parseLlmResult(
await envCall(
rpc,
"testLlm",
{ workspaceDir, pythonPath },
controller.signal,
),
);
if (controller.signal.aborted) return;
setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
} catch (error) {
if (controller.signal.aborted) return;
setLlmTest({
phase: "fail",
result: {
ok: false,
status: 0,
latencyMs: 0,
error: safeError(error),
},
});
} finally {
// An aborted probe never settles into a result; without this the
// button would stay disabled on "Testing…" until the tab remounts.
if (controller.signal.aborted && alive.current)
setLlmTest({ phase: "idle" });
}
},
[llmTest.phase, rpc, workspaceDir],
);
const saveWorkspace = useCallback(async () => {
if (!writable) return;
const next = workspaceInput ?? workspaceDir;
setWorkspaceInput(undefined);
const saved = await onWorkspaceDir(next.trim()).catch(() => false);
if (!alive.current) return;
// A rejected save is reported by the settings writer; do not reload the
// old directory as if the new one had been accepted.
if (saved === false) return;
setTick((current) => current + 1);
}, [onWorkspaceDir, workspaceDir, workspaceInput, writable]);
return {
env,
workspaceInput,
workspaceDraft: workspaceInput ?? workspaceDir,
editWorkspace: setWorkspaceInput,
saveWorkspace,
value,
entry,
edit,
saving,
result,
save,
llmTest,
testLlm,
};
}
/** One `.env` control generated from the field table. */ function EnvControl({
t,
spec,
workspace,
}: {
t: EnvTranslator;
spec: EnvFieldSpec;
workspace: WorkspaceState;
}): JSX.Element {
const entry = workspace.entry(spec.key);
const hint = spec.hint ? t(spec.hint) : undefined;
if (spec.kind === "select")
return (
<EnvField label={t(spec.label)}>
<select
value={workspace.value(spec.key)}
onChange={(event) => workspace.edit(spec.key, event.target.value)}
>
<option value="">—</option>
{envChoices(spec.options ?? [], workspace.value(spec.key)).map(
(name) => (
<option key={name} value={name}>
{name}
</option>
),
)}
</select>
</EnvField>
);
if (spec.kind === "secret")
return (
<EnvField
label={t(spec.label)}
hint={
entry?.set
? fill(t("secretSet"), { masked: entry.masked })
: t("secretUnset")
}
>
<input
type="password"
autoComplete="off"
value={workspace.value(spec.key)}
placeholder={entry?.masked ?? spec.disabledValue ?? ""}
onChange={(event) => workspace.edit(spec.key, event.target.value)}
/>
</EnvField>
);
return (
<EnvField label={t(spec.label)} hint={hint}>
<input
value={workspace.value(spec.key)}
placeholder={spec.placeholder}
onChange={(event) => workspace.edit(spec.key, event.target.value)}
/>
</EnvField>
);
}
/** Keep a value that is no longer offered as an option visible and selected. */
function envChoices(list: readonly string[], current: string): string[] {
return current && !list.includes(current) ? [...list, current] : [...list];
}
export interface WorkspaceSectionProps {
t: EnvTranslator;
rpc: StatusRpc;
/** The workspace whose `.env` this section reads; also its React key. */
workspaceDir: string;
pythonPath: string;
writable: boolean;
/** Persist a new workspace directory; resolves false when refused. */
onWorkspaceDir(dir: string): Promise<unknown>;
}
/** Step 3: the workspace `.env` ReMe reads when the service starts. */
export function WorkspaceSection({
t,
rpc,
workspaceDir,
pythonPath,
writable,
onWorkspaceDir,
}: WorkspaceSectionProps): JSX.Element {
const [open, setOpen] = useState(false);
// The section owns the workspace state, and the tab mounts it with
// `key={workspaceDir}`, so switching workspaces disposes the previous
// drafts, save message and LLM-test result instead of invalidating them
// field by field.
const workspace = useWorkspace(rpc, {
workspaceDir,
writable,
onWorkspaceDir,
});
const { env, llmTest, result } = workspace;
// The probe reads the saved .env, so a configuration that is only picked in
// the form would be tested against the one still on disk. The button waits
// instead of quietly testing the old configuration.
const savedValue = (key: string): string =>
env.data?.entries.find((entry) => entry.key === key)?.value ?? "";
// The backend falls back to the default on both sides, so picking the
// default in the form is not a change at all.
const backendChanged =
llmBackendOf(workspace.value("LLM_BACKEND")) !==
llmBackendOf(savedValue("LLM_BACKEND"));
// The model and the base URL travel as plain text, so they compare by
// value. The key is write-only: a typed one is the only sign that it
// differs from the file, which still holds the stored key.
const configChanged =
backendChanged ||
workspace.value("LLM_MODEL_NAME").trim() !==
savedValue("LLM_MODEL_NAME").trim() ||
workspace.value("LLM_BASE_URL").trim() !==
savedValue("LLM_BASE_URL").trim() ||
workspace.value("LLM_API_KEY") !== "";
return (
<section className={`reme-env-card${open ? "" : " collapsed"}`}>
<header>
<span className="reme-env-step" aria-hidden="true">
3
</span>
<div>
<h4>{t("configTitle")}</h4>
<p className="reme-env-summary">
{env.phase === "ready" && env.data
? `${env.data.exists ? env.data.envPath : t("envMissing")}${
workspace.value("LLM_MODEL_NAME").trim()
? ` · ${workspace.value("LLM_MODEL_NAME").trim()}`
: ""
}`
: t("envLoading")}
</p>
</div>
<aside>
<button
type="button"
className="reme-settings-button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{t(open ? "collapse" : "expand")}
</button>
</aside>
</header>
{open ? (
<div className="reme-env-card-body">
<p className="reme-settings-muted">{t("configHint")}</p>
<div className="reme-settings-grid">
<EnvField label={t("workspaceDir")} hint={t("workspaceDirHint")}>
<div className="reme-env-inline">
<input
value={workspace.workspaceDraft}
placeholder="~/.reme"
onChange={(event) =>
workspace.editWorkspace(event.target.value)
}
/>
<button
type="button"
className="reme-settings-button"
disabled={!writable || workspace.saving}
onClick={() => void workspace.saveWorkspace()}
>
{t("saveWorkspace")}
</button>
</div>
</EnvField>
</div>
{env.phase === "loading" ? (
<p className="reme-settings-muted">{t("envLoading")}</p>
) : null}
{env.phase === "error" ? (
<p className="reme-settings-error">
{t("saveEnvFailed")}
{env.error ? `: ${env.error}` : ""}
</p>
) : null}
{env.phase === "ready" && env.data ? (
<>
<p className="reme-settings-muted">
{env.data.exists
? `${t("envPath")}: ${env.data.envPath}`
: t("envMissing")}
</p>
<div className="reme-settings-grid reme-env-config">
{ENV_GROUPS.map((group) => (
<EnvGroupFields
key={group.title}
t={t}
group={group}
workspace={workspace}
/>
))}
</div>
<div className="reme-env-actions">
<button
type="button"
className="reme-settings-save"
disabled={workspace.saving || !writable}
onClick={() => void workspace.save()}
>
{t(workspace.saving ? "savingEnv" : "saveEnv")}
</button>
<button
type="button"
className="reme-settings-button"
disabled={
!pythonPath || llmTest.phase === "running" || configChanged
}
onClick={() => void workspace.testLlm(pythonPath)}
>
{t(
llmTest.phase === "running"
? "testingLlm"
: "testLlmButton",
)}
</button>
{configChanged ? (
<span className="reme-settings-muted">
{t("testLlmSaveFirst")}
</span>
) : null}
<span className="reme-settings-result" role="status">
{result.kind === "saved" ? (
<span className="reme-settings-success">
{fill(
// A save that left newer edits behind must not read
// like a clean form.
result.pending ? t("savedEnvPending") : t("savedEnv"),
{ path: env.data.envPath },
)}
</span>
) : null}
{result.kind === "failed" ? (
<span className="reme-settings-error">
{`${t("saveEnvFailed")}${
result.message ? `: ${result.message}` : ""
}`}
</span>
) : null}
{llmTest.phase === "ok" && llmTest.result ? (
<span className="reme-settings-success">
{fill(t("llmOk"), { ms: llmTest.result.latencyMs })}
</span>
) : null}
{llmTest.phase === "fail" && llmTest.result ? (
<span className="reme-settings-error">
{`${t("llmFailed")}${
llmTest.result.error ? `: ${llmTest.result.error}` : ""
}`}
</span>
) : null}
</span>
</div>
</>
) : null}
</div>
) : null}
</section>
);
}
/** One titled group of `.env` controls. */
function EnvGroupFields({
t,
group,
workspace,
}: {
t: EnvTranslator;
group: EnvGroup;
workspace: WorkspaceState;
}): JSX.Element {
return (
<>
<h5 className="reme-env-group">{t(group.title)}</h5>
{group.fields.map((spec) => (
<EnvControl key={spec.key} t={t} spec={spec} workspace={workspace} />
))}
</>
);
}

View file

@ -0,0 +1,14 @@
/** Shared, browser-safe readiness rule for the Host and setup cards. */
import type { RemeCheck } from "./types.js";
/**
* Supported ReMe releases import AgentScope while loading the CLI, so a bare
* installation cannot start the service. Install `reme-ai[core]` first.
*/
export function canHostService(check: RemeCheck | undefined): boolean {
return (
check?.installed === true &&
check.versionOk === true &&
check.hasCore === true
);
}

View file

@ -0,0 +1,52 @@
/**
* Canonical identity of an endpoint, shared by the Host (service records) and
* the tab (matching an answer to the URL on screen).
*
* Loopback aliases and default ports compare equal, so saving
* `localhost:2333` does not make the service this plugin started on
* `127.0.0.1:2333` look foreign. This module stays browser-safe: no Node
* built-ins, because the client imports it too.
*/
/**
* The origin and root-normalized path of an endpoint, when it is parseable.
*
* Loopback aliases and default ports are already folded here, so both keys
* below compare the same spelling of the same endpoint.
*/
function endpointParts(
endpoint: string,
): { origin: string; path: string } | undefined {
let url: URL;
try {
url = new URL(endpoint.trim());
} catch {
return undefined;
}
const hostname = url.hostname.toLowerCase();
const host = hostname === "localhost" ? "127.0.0.1" : hostname;
const port = url.port || (url.protocol === "https:" ? "443" : "80");
return {
origin: `${url.protocol}//${host}:${port}`,
path: url.pathname.replace(/\/+$/, ""),
};
}
export function serviceEndpointKey(endpoint: string): string {
const parts = endpointParts(endpoint);
return parts ? `${parts.origin}${parts.path}` : endpoint.trim();
}
/**
* Canonical identity of an endpoint's origin, ignoring its path.
*
* The service launch configures only host and port, so two endpoints on the
* same origin name the same local process even when their paths differ. A
* record left by a version that still launched a prefixed endpoint (for
* example `/reme`) therefore still describes the service a start on the
* origin root has to replace.
*/
export function serviceOriginKey(endpoint: string): string {
const parts = endpointParts(endpoint);
return parts ? parts.origin : endpoint.trim();
}

View file

@ -0,0 +1,73 @@
/**
* The endpoint rules the Host and the tab must agree on.
*
* The plugin launches `reme start` with `service.host=127.0.0.1` and a plain
* HTTP service, so only a loopback HTTP endpoint can ever become a managed
* local service. The tab decides whether to show the local steps at all and
* the Host decides whether it may act; both read the same rules here, so the
* browser cannot offer a local service the Host would refuse.
*
* This module stays browser-safe: the client imports it too.
*/
/** The host and port of a manageable local endpoint. */
export interface LocalEndpoint {
host: string;
port: number;
}
/**
* The host and port of a manageable local endpoint.
*
* Throws when the endpoint is not one, so the caller can report why. An
* https loopback URL is refused: the service this plugin launches is plain
* HTTP, so an https URL could never become healthy. A loopback URL with a path
* prefix is refused too: the launch only sets host and port, so the service
* answers at the origin root and the prefixed health check could never pass.
*/
export function parseLocalEndpoint(endpoint: string): LocalEndpoint {
let url: URL;
try {
url = new URL(endpoint.trim());
} catch {
throw new TypeError("Invalid ReMe endpoint");
}
if (url.protocol !== "http:")
throw new TypeError(
"Local service management requires an http:// endpoint",
);
// The service is launched with `service.host=127.0.0.1`; accepting an IPv6
// loopback would spawn a process that can never answer that endpoint.
if (!["127.0.0.1", "localhost"].includes(url.hostname))
throw new TypeError(
"Local service management requires an IPv4 loopback endpoint",
);
const port = Number(url.port || "80");
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new TypeError("Invalid ReMe endpoint port");
// The launch configures `service.host` and `service.port` only, so the
// service always answers at the origin root. A path prefix (a gateway that
// mounts ReMe under, say, `/reme`) would make the startup health check at
// `/reme/health_check` 404, so such an endpoint is not launchable here. It
// stays a remote endpoint and is health-checked at its own path.
if (url.pathname !== "/" && url.pathname !== "")
throw new TypeError(
"Local service management requires a root-path endpoint",
);
return { host: "127.0.0.1", port };
}
/**
* Whether the endpoint names a service this plugin can manage locally.
*
* A remote endpoint is still health-checked, but its local steps are hidden:
* the browser and the Host may be different machines.
*/
export function isLocalEndpoint(endpoint: string): boolean {
try {
parseLocalEndpoint(endpoint);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,168 @@
/**
* The `.env` keys the Environment tab manages, and the rules for updating them.
*
* This module is the single source of truth for the managed keys and for which
* of them are write-only secrets. An empty secret draft means "leave blank to
* keep" and an empty non-secret draft is an explicit clear; the rules are
* derived from the `secret` flag alone, so a key cannot carry a rule that
* contradicts its own kind. A key that names a default (`defaultValue`) is the
* one exception: its empty draft means "use the default", and the default is
* written out, because the service only falls back to it when the variable is
* absent. The Host and the browser both derive their behaviour from it, so the
* two can never drift apart about the same file.
*
* It stays browser-safe: no node built-in is imported here, and the browser
* bundle must stay loadable without one.
*/
import { DEFAULT_LLM_BACKEND } from "./llm-backend.js";
export interface EnvFieldMeta {
/** The `.env` key. */
key: string;
/**
* True for write-only credentials; the stored value never rides a response.
* An empty draft for such a key writes nothing.
*/
secret: boolean;
/**
* Value written when the key is unset and must not stay empty.
*
* ReMe 0.4.0.x instantiates the embedding component unconditionally, so
* `EMBEDDING_API_KEY` has to hold a non-empty value even when vector search
* is off; `EMPTY` is the documented placeholder for "no embedding key".
*/
disabledValue?: string;
/**
* The value an empty selection is written as.
*
* An empty selection means "use the default". The default is written
* explicitly, because ReMe's configuration only falls back to it when the
* variable is absent: an empty value stays empty and names no backend, and
* the service then refuses to start.
*/
defaultValue?: string;
}
/**
* The managed keys, in the order the two form groups show them.
*
* The order is the order the summary reports them in, so the Host projection
* and the browser form list the same keys in the same order.
*/
export const ENV_FIELDS: readonly EnvFieldMeta[] = [
{
key: "LLM_BACKEND",
secret: false,
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is
// absent, so a cleared selection must write the default out rather than
// an empty value.
defaultValue: DEFAULT_LLM_BACKEND,
},
{ key: "LLM_MODEL_NAME", secret: false },
{ key: "LLM_API_KEY", secret: true },
{ key: "LLM_BASE_URL", secret: false },
{
key: "EMBEDDING_BACKEND",
secret: false,
// ReMe 0.4.0.x declares `as_embedding` with
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so the
// same rule holds here: the default is written out, because an empty
// value names no backend and the service then refuses to start.
defaultValue: "openai",
},
{ key: "EMBEDDING_MODEL_NAME", secret: false },
{
key: "EMBEDDING_API_KEY",
secret: true,
disabledValue: "EMPTY",
},
{ key: "EMBEDDING_BASE_URL", secret: false },
];
/** Every key the Environment tab manages. */
export const MANAGED_KEYS: readonly string[] = ENV_FIELDS.map(
(field) => field.key,
);
/** The keys whose stored value is never returned to the browser. */
export const SECRET_KEYS: ReadonlySet<string> = new Set(
ENV_FIELDS.filter((field) => field.secret).map((field) => field.key),
);
/** The metadata of one managed key. */
export function envFieldMeta(key: string): EnvFieldMeta | undefined {
return ENV_FIELDS.find((field) => field.key === key);
}
/**
* Whether the file already holds a non-empty value for each managed key.
*
* The Host builds it from the parsed file; the tab builds it from the masked
* projection it received, which never carries a secret's value.
*/
export function storedState(
values: Readonly<Record<string, string>>,
): Record<string, boolean> {
const state: Record<string, boolean> = {};
for (const field of ENV_FIELDS)
state[field.key] = (values[field.key] ?? "") !== "";
return state;
}
/**
* The update a draft of the managed keys produces.
*
* Only touched keys travel: an untouched key keeps its stored value. An empty
* secret draft is dropped rather than written, so "leave blank to keep" holds
* even after the user typed a replacement and cleared it again. An empty
* non-secret draft is an explicit clear and keeps that meaning — unless the
* key names a default, in which case the empty selection means "use the
* default" and the default is written out.
*
* `stored` reports, per key, whether the file already holds a non-empty value.
* It is what decides whether a key that must not stay empty gets its
* `disabledValue` marker.
*/
export function envUpdates(
draft: Readonly<Record<string, string>>,
stored: Readonly<Record<string, boolean>>,
): Record<string, string> {
const updates: Record<string, string> = {};
// A key the tab does not manage travels unchanged: the Host must not
// silently drop a value it was asked to write.
for (const [key, value] of Object.entries(draft))
if (!envFieldMeta(key)) updates[key] = value;
for (const field of ENV_FIELDS) {
const typed = draft[field.key];
const isSet = stored[field.key] === true;
if (typed === undefined) {
// Untouched: the stored value survives. The one exception is a key that
// must not stay empty, which gets its documented placeholder.
if (field.disabledValue && !isSet)
updates[field.key] = field.disabledValue;
continue;
}
if (typed === "") {
// "Leave blank to keep": a secret is write-only, so an empty draft
// cannot mean "delete my key"; it writes nothing and the stored
// credential survives.
if (field.secret) {
if (field.disabledValue && !isSet)
updates[field.key] = field.disabledValue;
continue;
}
// "Use the default": the default is written out, because the service
// only falls back to it when the variable is absent. An empty value
// would name no backend at all.
if (field.defaultValue) {
updates[field.key] = field.defaultValue;
continue;
}
updates[field.key] = "";
continue;
}
updates[field.key] = typed;
}
return updates;
}

View file

@ -0,0 +1,593 @@
import { win32 } from "node:path";
import { safeError } from "../../endpoint.js";
import { pathOf } from "./paths.js";
import type { RunCommand, RunResult } from "./runner.js";
import { probeReme, remeVersionAtLeast } from "./reme.js";
import type { PythonDetection, PythonEnv, PythonOrigin } from "../types.js";
/** ReMe requires Python 3.11 or newer. */
export const MINIMUM_PYTHON = "3.11";
/**
* Ask one interpreter for its real executable, version and base prefix.
* Kept as a single self-contained -c snippet so no helper files are needed.
*/
const VERIFY_SNIPPET =
'import sys,json;print(json.dumps({"exe":sys.executable,"ver":"%d.%d.%d"%sys.version_info[:3],"base":sys.base_prefix}))';
export interface DetectIO {
run: RunCommand;
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
homedir: string;
exists(path: string): boolean;
}
export interface VerifyInfo {
exe: string;
version: string;
basePrefix: string;
}
/* ------------------------------------------------------------------ */
/* Pure parsers (unit-tested against real tool output fixtures). */
/* ------------------------------------------------------------------ */
/** Compare "major.minor[.patch]" strings numerically. */
export function versionAtLeast(
version: string,
minimum: string = MINIMUM_PYTHON,
): boolean {
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
if (actual.length < 2 || actual.some((part) => !Number.isFinite(part)))
return false;
const wanted = minimum.split(".").map((part) => Number.parseInt(part, 10));
for (let index = 0; index < wanted.length; index += 1) {
const left = actual[index] ?? 0;
const right = wanted[index] ?? 0;
if (left !== right) return left > right;
}
return true;
}
/** Parse the VERIFY_SNIPPET line out of mixed stdout (banners, warnings). */
export function parseVerifyOutput(text: string): VerifyInfo | undefined {
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const value = JSON.parse(trimmed) as Record<string, unknown>;
const exe = typeof value.exe === "string" ? value.exe : "";
const version = typeof value.ver === "string" ? value.ver : "";
const base = typeof value.base === "string" ? value.base : "";
if (exe && version) return { exe, version, basePrefix: base };
} catch {
// Not JSON; try the next line.
}
}
return undefined;
}
export interface RegPython {
exe: string;
source: string;
}
/**
* Parse `reg query <hive> /s /ve` output for PEP 514 InstallPath defaults.
* The default-value label is localized and arrives mojibaked when reg.exe
* writes GBK into a UTF-8 pipe, so match the ASCII "REG_SZ" marker instead.
*/
export function parseRegQuery(text: string): RegPython[] {
const found: RegPython[] = [];
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const key = (lines[index] ?? "").trim();
if (!key.startsWith("HKEY_") || !key.endsWith("\\InstallPath")) continue;
for (
let offset = 1;
offset <= 3 && index + offset < lines.length;
offset += 1
) {
const line = (lines[index + offset] ?? "").trim();
if (line.startsWith("HKEY_")) break;
const match = /REG_SZ\s+(.+)$/i.exec(line);
if (!match) continue;
const dir = (match[1] ?? "").trim();
if (!dir || /^\(.*\)$/.test(dir)) break;
const exe = /python[^\\/]*\.exe$/i.test(dir)
? dir
: `${dir.replace(/[\\/]+$/, "")}\\python.exe`;
found.push({ exe, source: `registry ${key}` });
break;
}
}
return found;
}
/** Parse `py -0p` lines: "-V:3.12 * D:\...\python.exe". */
export function parsePyListPaths(text: string): string[] {
const paths: string[] = [];
for (const line of text.split(/\r?\n/)) {
const match = /-V:\d+(?:\.\d+)?\s+\*?\s*(\S.*)$/.exec(line);
const exe = (match?.[1] ?? "").trim();
if (exe && /python[^\\/]*\.exe$/i.test(exe)) paths.push(exe);
}
return paths;
}
/** Parse `where`/`which -a` output into trimmed, non-empty lines. */
export function parsePathList(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 2);
}
/**
* Microsoft Store installs real interpreters under
* WindowsApps\PythonSoftwareFoundation.*, while the alias stubs sit directly
* in WindowsApps and only open the Store. Only the stubs must be dropped.
*/
export function isWindowsAppsAlias(exe: string): boolean {
const normalized = exe.replaceAll("/", "\\").toLowerCase();
const cut = normalized.lastIndexOf("\\");
if (cut < 0) return false;
const parent = normalized.slice(0, cut);
const parentName = parent.slice(parent.lastIndexOf("\\") + 1);
return parentName === "windowsapps";
}
function isWindowsStorePackage(exe: string): boolean {
return /\\windowsapps\\pythonsoftwarefoundation\./i.test(
exe.replaceAll("/", "\\"),
);
}
export function parseCondaEnvs(text: string): string[] {
try {
const value = JSON.parse(text) as { envs?: unknown };
if (!Array.isArray(value.envs)) return [];
return value.envs.filter(
(item): item is string => typeof item === "string" && item.length > 0,
);
} catch {
return [];
}
}
export function parseCondaRoot(text: string): string {
try {
const value = JSON.parse(text) as { root_prefix?: unknown };
return typeof value.root_prefix === "string" ? value.root_prefix : "";
} catch {
return "";
}
}
/* ------------------------------------------------------------------ */
/* Conda command runner: prefer the real conda.exe, fall back to .bat. */
/* ------------------------------------------------------------------ */
export type CondaRunner = (
args: string[],
timeoutMs?: number,
) => Promise<RunResult>;
/** Derive the conda installation root from any conda executable path. */
export function condaRootFromPath(condaPath: string): string {
// The path may be written for another platform than the host, so its flavor
// decides the semantics: dirname must not depend on where tests run.
const path = pathOf(condaPath);
const dir = path.dirname(condaPath);
const lower = dir.toLowerCase();
if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin"))
return path.dirname(path.dirname(dir));
if (lower.endsWith("\\scripts") || lower.endsWith("/scripts"))
return path.dirname(dir);
if (lower.endsWith("\\condabin") || lower.endsWith("/condabin"))
return path.dirname(dir);
if (lower.endsWith("\\bin") || lower.endsWith("/bin"))
return path.dirname(dir);
return "";
}
/**
* Windows ships conda as .bat files that Node cannot spawn without a shell;
* run the real Scripts\conda.exe when present, otherwise wrap the .bat in
* cmd.exe with verbatim argv. The path comes from discovery, never user input.
*/
export function makeCondaRunner(
run: RunCommand,
condaPath: string,
windows: boolean,
exists: (path: string) => boolean,
): CondaRunner {
const root = condaRootFromPath(condaPath);
const exeCandidates: string[] = [];
if (root && windows)
exeCandidates.push(win32.join(root, "Scripts", "conda.exe"));
if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath);
return async (args, timeoutMs = 15_000) => {
for (const candidate of exeCandidates) {
if (!exists(candidate)) continue;
const result = await run(candidate, args, { timeoutMs });
if (!result.error) return result;
}
if (windows)
return run(
"cmd.exe",
["/d", "/s", "/c", `"${condaPath}" ${args.join(" ")}`],
{ timeoutMs, windowsVerbatimArguments: true },
);
return run(condaPath, args, { timeoutMs });
};
}
/**
* Unix interpreters that a `which -a python3` can miss when the shell has a
* trimmed PATH: the system install, Homebrew on both Apple Silicon and Intel,
* and pyenv/local shims. Windows has `where` and the registry for this.
*/
function commonPythonPaths(io: DetectIO, windows: boolean): string[] {
if (windows) return [];
const path = pathOf(io.homedir);
return [
"/usr/bin/python3",
"/usr/bin/python",
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
path.join(io.homedir, ".pyenv", "shims", "python3"),
path.join(io.homedir, ".local", "bin", "python3"),
];
}
function commonCondaPaths(io: DetectIO, windows: boolean): string[] {
const programData = io.env["ProgramData"] ?? "";
const path = windows ? win32 : pathOf(io.homedir);
const candidates = windows
? [
path.join(io.homedir, "miniconda3", "Scripts", "conda.exe"),
path.join(io.homedir, "anaconda3", "Scripts", "conda.exe"),
path.join(programData, "miniconda3", "Scripts", "conda.exe"),
path.join(programData, "anaconda3", "Scripts", "conda.exe"),
]
: [
path.join(io.homedir, "miniconda3", "bin", "conda"),
path.join(io.homedir, "anaconda3", "bin", "conda"),
path.join(io.homedir, "miniforge3", "bin", "conda"),
"/opt/miniconda3/bin/conda",
"/opt/anaconda3/bin/conda",
"/opt/homebrew/Caskroom/miniconda/base/bin/conda",
];
return candidates.filter((candidate) => path.isAbsolute(candidate));
}
/* ------------------------------------------------------------------ */
/* Orchestration. */
/* ------------------------------------------------------------------ */
interface Candidate {
exe: string;
sources: Set<string>;
}
function normalizeExe(exe: string, windows: boolean): string {
const trimmed = exe.trim();
if (!trimmed) return "";
// Unify separators for the simulated platform: resolve() follows the host,
// but detection must behave identically on every host.
const unified = windows
? trimmed.replaceAll("/", "\\")
: trimmed.replaceAll("\\", "/");
return windows ? unified.toLowerCase() : unified;
}
function addCandidate(
candidates: Map<string, Candidate>,
exe: string,
source: string,
windows: boolean,
): void {
const key = normalizeExe(exe, windows);
if (!key) return;
const existing = candidates.get(key);
if (existing) existing.sources.add(source);
else candidates.set(key, { exe: exe.trim(), sources: new Set([source]) });
}
function pathContains(dir: string, exe: string, windows: boolean): boolean {
const path = windows ? win32 : pathOf(exe);
const base = normalizeExe(path.resolve(dir), windows);
const full = normalizeExe(path.resolve(exe), windows);
return full.startsWith(`${base}${windows ? "\\" : "/"}`);
}
/** prefix of an interpreter install from its executable location. */
function pythonPrefix(exe: string, windows: boolean): string {
const path = windows ? win32 : pathOf(exe);
const parent = path.dirname(exe);
return windows || path.basename(parent) !== "bin"
? parent
: path.dirname(parent);
}
function isVenv(info: VerifyInfo, windows: boolean): boolean {
if (!info.basePrefix) return false;
return (
normalizeExe(pythonPrefix(info.exe, windows), windows) !==
normalizeExe(info.basePrefix, windows)
);
}
const KIND_RANK: Record<PythonOrigin["kind"], number> = {
conda: 0,
"python.org": 1,
"windows-store": 2,
venv: 3,
system: 4,
unknown: 5,
};
function classifyOrigin(
info: VerifyInfo,
candidate: Candidate,
conda: { root: string; envs: string[] },
windows: boolean,
): PythonOrigin {
// Named envs live under the root too, so they must be matched first.
for (const envPath of conda.envs) {
if (envPath === conda.root) continue;
if (pathContains(envPath, info.exe, windows))
return {
kind: "conda",
envName: (windows ? win32 : pathOf(envPath)).basename(envPath),
root: envPath,
};
}
if (conda.root && pathContains(conda.root, info.exe, windows))
return { kind: "conda", envName: "base", root: conda.root };
if (isWindowsStorePackage(info.exe)) return { kind: "windows-store" };
if (isVenv(info, windows))
return {
kind: "venv",
baseExe: windows
? win32.join(info.basePrefix, "python.exe")
: pathOf(info.basePrefix).join(info.basePrefix, "bin", "python3"),
};
if ([...candidate.sources].some((source) => source.startsWith("registry")))
return { kind: "python.org" };
if (
candidate.sources.has("PATH") ||
candidate.sources.has("which") ||
candidate.sources.has("well-known")
)
return { kind: "system" };
return { kind: "unknown" };
}
function compareEnvs(a: PythonEnv, b: PythonEnv): number {
if (a.ok !== b.ok) return a.ok ? -1 : 1;
const rank = KIND_RANK[a.origin.kind] - KIND_RANK[b.origin.kind];
if (rank !== 0) return rank;
const nameA = a.origin.kind === "conda" ? a.origin.envName : "";
const nameB = b.origin.kind === "conda" ? b.origin.envName : "";
if (nameA !== nameB) return nameA.localeCompare(nameB);
return a.exe.localeCompare(b.exe);
}
/** Prefer a named conda env with a usable reme, then any reme env. */
export function suggestInterpreter(envs: PythonEnv[]): string {
const usable = envs.filter((env) => env.ok);
// An installed-but-outdated reme does not make an interpreter ready.
const withReme = usable.filter(
(env) =>
env.reme?.installed === true && remeVersionAtLeast(env.reme.version),
);
const named = (list: PythonEnv[]): PythonEnv | undefined =>
list.find(
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
);
return (
(named(withReme) ?? withReme[0] ?? named(usable) ?? usable[0])?.exe ?? ""
);
}
/** Probe reme-ai in every interpreter so the list shows setup state. */
async function enrichWithReme(envs: PythonEnv[], io: DetectIO): Promise<void> {
const queue = [...envs];
const worker = async (): Promise<void> => {
for (;;) {
const env = queue.shift();
if (!env) return;
env.reme = await probeReme(io, env.exe);
}
};
await Promise.all(
Array.from({ length: Math.min(6, envs.length) }, () => worker()),
);
}
async function verifyCandidates(
candidates: Map<string, Candidate>,
io: DetectIO,
): Promise<Map<string, VerifyInfo>> {
const verified = new Map<string, VerifyInfo>();
const queue = [...candidates.entries()];
const worker = async (): Promise<void> => {
for (;;) {
const entry = queue.shift();
if (!entry) return;
const [key, candidate] = entry;
let result: RunResult;
try {
result = await io.run(candidate.exe, ["-c", VERIFY_SNIPPET], {
timeoutMs: 8_000,
});
} catch {
continue;
}
if (result.error || result.code !== 0) continue;
const info = parseVerifyOutput(result.stdout);
if (!info) continue;
// Prefer the interpreter's own sys.executable (resolves symlinks/shims).
verified.set(key, { ...info, exe: info.exe || candidate.exe });
}
};
await Promise.all(
Array.from({ length: Math.min(6, queue.length) }, () => worker()),
);
return verified;
}
/**
* Discover local Python interpreters, verify each one by running it, and
* classify origins. Discovery failures are collected; they never reject.
*/
export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
const windows = io.platform === "win32";
const errors: string[] = [];
const candidates = new Map<string, Candidate>();
let condaRoot = "";
let condaEnvs: string[] = [];
const steps: Promise<void>[] = [
// PEP 514 registry (Windows; Miniconda registers itself here too).
(async () => {
if (!windows) return;
const hives = [
"HKCU\\Software\\Python",
"HKLM\\Software\\Python",
"HKLM\\Software\\WOW6432Node\\Python",
];
for (const hive of hives) {
const result = await io.run("reg.exe", ["query", hive, "/s", "/ve"], {
timeoutMs: 5_000,
});
if (result.error) continue;
for (const { exe, source } of parseRegQuery(result.stdout))
addCandidate(candidates, exe, source, windows);
}
})(),
// py launcher (Windows only; usually absent without a python.org install).
(async () => {
if (!windows) return;
const result = await io.run("py.exe", ["-0p"], { timeoutMs: 5_000 });
if (result.error) return;
for (const exe of parsePyListPaths(result.stdout))
addCandidate(candidates, exe, "py launcher", windows);
})(),
// PATH / which.
(async () => {
const first = windows
? await io.run("where.exe", ["python"], { timeoutMs: 5_000 })
: await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 });
// A non-zero exit just means "no match"; a spawn/timeout error is a real
// diagnostic and must not disappear from the report.
if (first.error) errors.push(`python: ${safeError(first.error)}`);
let stdout = first.stdout;
if (!windows) {
const extra = await io.run("which", ["-a", "python"], {
timeoutMs: 5_000,
});
if (extra.error) errors.push(`python: ${safeError(extra.error)}`);
stdout = `${stdout}\n${extra.stdout}`;
}
for (const exe of parsePathList(stdout)) {
if (isWindowsAppsAlias(exe)) continue;
addCandidate(candidates, exe, windows ? "PATH" : "which", windows);
}
// Well-known Unix install locations, independent of the Host PATH.
for (const exe of commonPythonPaths(io, windows))
if (io.exists(exe))
addCandidate(candidates, exe, "well-known", windows);
})(),
// conda: PATH first, then well-known install roots.
(async () => {
const located = windows
? parsePathList(
(await io.run("where.exe", ["conda"], { timeoutMs: 5_000 })).stdout,
)
: parsePathList(
(await io.run("which", ["-a", "conda"], { timeoutMs: 5_000 }))
.stdout,
);
for (const path of commonCondaPaths(io, windows))
if (io.exists(path)) located.push(path);
if (!located.length) return;
const runConda = makeCondaRunner(
io.run,
located[0] ?? "",
windows,
io.exists,
);
const info = await runConda(["info", "--json"]);
if (info.error)
errors.push(
`conda: discovered but not runnable (${safeError(info.error)})`,
);
condaRoot = info.error ? "" : parseCondaRoot(info.stdout);
const listing = await runConda(["env", "list", "--json"]);
condaEnvs = listing.error ? [] : parseCondaEnvs(listing.stdout);
if (!condaRoot && condaEnvs.length) condaRoot = condaEnvs[0] ?? "";
for (const envPath of condaEnvs) {
addCandidate(
candidates,
windows
? win32.join(envPath, "python.exe")
: pathOf(envPath).join(envPath, "bin", "python3"),
"conda env list",
windows,
);
}
})().catch((error: unknown) => {
errors.push(`conda: ${safeError(error)}`);
}),
];
await Promise.allSettled(steps);
const verified = await verifyCandidates(candidates, io);
// Candidates can resolve to the same real executable (aliases, shims);
// merge their discovery sources so the list stays duplicate-free.
const merged = new Map<string, PythonEnv>();
for (const [key, candidate] of candidates) {
const info = verified.get(key);
if (!info) continue;
const exe = info.exe || candidate.exe;
const dedupeKey = normalizeExe(exe, windows);
const existing = merged.get(dedupeKey);
if (existing) {
existing.source = [
...new Set([...existing.source.split("; "), ...candidate.sources]),
]
.sort()
.join("; ");
continue;
}
merged.set(dedupeKey, {
exe,
version: info.version,
ok: versionAtLeast(info.version),
origin: classifyOrigin(
info,
candidate,
{ root: condaRoot, envs: condaEnvs },
windows,
),
source: [...candidate.sources].sort().join("; "),
});
}
const envs = [...merged.values()];
await enrichWithReme(envs, io);
envs.sort(compareEnvs);
return {
platform: io.platform,
scannedAt: new Date().toISOString(),
envs,
suggested: suggestInterpreter(envs),
condaRoot,
errors,
};
}

View file

@ -0,0 +1,212 @@
import type { Context } from "@deepseek-ai/cordis";
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
import type {
ConnectionHealth,
EnvSummary,
InstallStatus,
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceStartResult,
ServiceState,
ServiceStopResult,
} from "../types.js";
import type { ReMeEnvManager } from "./manager.js";
declare module "@deepseek-ai/cordis" {
interface Context {
/** Local environment manager mounted by the DSH adapter. */
remeEnv: ReMeEnvManager;
}
}
/**
* Host projection for the environment management tab.
*
* DSH exposes these methods in SRC mode (no generated typert artifact), which
* parses the method source: every method takes exactly one plain identifier
* parameter named `input`, with no destructuring, defaults, or rest, and the
* client sends `{ args: { input: ... } }`.
*/
export class ReMeEnvGateway extends TypertRemoteService {
static inject = ["remeEnv"];
constructor(ctx: Context) {
// The service key must differ from the manager registered as `remeEnv`:
// TypertRemoteService registers itself under this key, and two providers
// for one key fail plugin activation. The wire namespace stays `remeEnv`,
// which is the endpoint the client calls.
super(ctx, "remeEnvGateway", { namespace: "remeEnv" });
}
/** Detect local Python interpreters; cached briefly unless refreshed. */
@Remote("python")
async python(
input: { refresh?: boolean } | undefined,
): Promise<PythonDetection> {
return this.ctx.remeEnv.detectPython(input ?? {});
}
/** Inspect reme-ai, the [core] extras and the CLI for one interpreter. */
@Remote("checkReme")
async checkReme(
input: { pythonPath?: string } | undefined,
): Promise<RemeCheck> {
return this.ctx.remeEnv.checkReme(input?.pythonPath ?? "");
}
/** Start the one-click pip install and return the running job. */
@Remote("install")
async install(
input:
| {
pythonPath?: string;
upgrade?: boolean;
indexUrl?: string;
forceReinstall?: boolean;
}
| undefined,
): Promise<InstallStatus> {
return this.ctx.remeEnv.install({
pythonPath: input?.pythonPath ?? "",
upgrade: input?.upgrade === true,
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
forceReinstall: input?.forceReinstall === true,
});
}
/** Latest (or named) install job progress, including the log tail. */
@Remote("installStatus")
async installStatus(
input: { jobId?: string; pythonPath?: string } | undefined,
): Promise<InstallStatus | null> {
return this.ctx.remeEnv.installStatus(
input?.jobId ?? "",
input?.pythonPath ?? "",
);
}
/** Ask a running install to stop; polling observes the cancelled phase. */
@Remote("installCancel")
async installCancel(input: { jobId?: string } | undefined): Promise<boolean> {
return this.ctx.remeEnv.installCancel(input?.jobId ?? "");
}
/** Masked .env values for the workspace; secrets are never returned. */
@Remote("readEnv")
async readEnv(
input: { workspaceDir?: string } | undefined,
): Promise<EnvSummary> {
return this.ctx.remeEnv.readEnv(input?.workspaceDir ?? "");
}
/** Merge edited values into the workspace .env and return fresh state. */
@Remote("writeEnv")
async writeEnv(
input:
| { workspaceDir?: string; values?: Record<string, string> }
| undefined,
): Promise<EnvSummary> {
return this.ctx.remeEnv.writeEnv(
input?.workspaceDir ?? "",
input?.values ?? {},
);
}
/** Probe the configured LLM endpoint with the selected interpreter. */
@Remote("testLlm")
async testLlm(
input: { workspaceDir?: string; pythonPath?: string } | undefined,
): Promise<LlmTestResult> {
return this.ctx.remeEnv.testLlm(
input?.workspaceDir ?? "",
input?.pythonPath ?? "",
);
}
/**
* Probe the configured endpoint from this Host. The Environment hero and the
* service card both read Host results, so a browser on another machine cannot
* disagree with the Host about "local".
*/
@Remote("health")
async health(
input: { endpoint?: string; authToken?: string } | undefined,
): Promise<ConnectionHealth> {
return this.ctx.remeEnv.health(
input?.endpoint ?? "",
input?.authToken ?? "",
);
}
/** Phase of the local ReMe service: running, conflict or stopped. */
@Remote("serviceStatus")
async serviceStatus(
input: { endpoint?: string; authToken?: string } | undefined,
): Promise<ServiceState> {
return this.ctx.remeEnv.serviceStatus(
input?.endpoint ?? "",
input?.authToken ?? "",
);
}
/** Spawn a detached `reme start`; poll serviceStatus until healthy. */
@Remote("serviceStart")
async serviceStart(
input:
| {
endpoint?: string;
pythonPath?: string;
workspaceDir?: string;
authToken?: string;
}
| undefined,
): Promise<ServiceStartResult> {
return this.ctx.remeEnv.serviceStart(
input?.endpoint ?? "",
input?.pythonPath ?? "",
input?.workspaceDir ?? "",
input?.authToken ?? "",
);
}
/** Stop the plugin-managed service; foreign instances are untouched. */
@Remote("serviceStop")
async serviceStop(
input: { endpoint?: string } | undefined,
): Promise<ServiceStopResult> {
return this.ctx.remeEnv.serviceStop(input?.endpoint ?? "");
}
/**
* Drop the service record without signalling the process. This is the way
* out of a live record whose identity cannot be verified, where Start and
* Stop both refuse to act.
*
* `endpoint` is the record's own endpoint, which is not necessarily the one
* on screen, and `expectPid` pins the record so a replaced one is not
* dropped silently.
*/
@Remote("serviceForget")
async serviceForget(
input: { endpoint?: string; expectPid?: number } | undefined,
): Promise<ServiceForgetResult> {
return this.ctx.remeEnv.serviceForget(
input?.endpoint ?? "",
typeof input?.expectPid === "number" ? input.expectPid : undefined,
);
}
/** Tail of the workspace service log for start-failure diagnosis. */
@Remote("serviceLog")
async serviceLog(
input: { workspaceDir?: string } | undefined,
): Promise<ServiceLog> {
return this.ctx.remeEnv.serviceLog(input?.workspaceDir ?? "");
}
}
export default ReMeEnvGateway;

View file

@ -0,0 +1,551 @@
import { spawn } from "node:child_process";
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
import { join } from "node:path";
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
import { interpreterKey } from "../interpreter-key.js";
import { INSTALL_LOG_KEEP, openAppendLog, pruneInstallLogs } from "./log.js";
import { isAbsolutePath } from "./paths.js";
import { processGroupAlive } from "./runner.js";
export interface InstallChild {
pid: number | undefined;
onExit(callback: (code: number | null) => void): void;
/** `force` escalates to SIGKILL on POSIX; Windows always force-kills. */
kill(force?: boolean): void;
/**
* Whether any process of the managed process group still exists.
*
* Only the leader's exit is observable through `onExit`, so a build child
* that ignored the graceful signal can outlive it. POSIX starts the install
* in its own group and can answer this; Windows kills the tree with
* `taskkill /T`, so it reports false. Omitted when the group cannot be
* inspected, which makes an observed exit settle the job.
*/
groupAlive?(): boolean;
}
export interface InstallSpawner {
start(options: {
exe: string;
args: string[];
logPath: string;
}): InstallChild;
}
/** Compose the pip invocation for `reme-ai[core]`. */
export function pipArgs(request: InstallRequest): string[] {
const args = [
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
];
if (request.upgrade) args.push("-U");
// `pip install reme-ai[core]` reports "already satisfied" for a broken but
// present install, so an explicit Reinstall must force it.
if (request.forceReinstall) args.push("--force-reinstall");
const indexUrl = (request.indexUrl ?? "").trim();
if (indexUrl) {
if (!/^https?:\/\/\S+$/i.test(indexUrl))
throw new TypeError("Package index must be an http(s) URL");
// Credentials embedded in the URL would be echoed in the job command, the
// install log and the browser; pip's own config is the safe place for them.
const parsed = new URL(indexUrl);
if (parsed.username || parsed.password)
throw new TypeError(
"Package index must not embed credentials; configure them in pip.conf",
);
args.push("--index-url", indexUrl);
}
args.push("reme-ai[core]");
return args;
}
/**
* Last `maxLines` non-empty lines of a log file, normalized to "\n". Only the
* tail is read: this runs on every 2 s poll and the log can grow for minutes.
*/
export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
let fd: number | undefined;
try {
fd = openSync(path, "r");
const size = fstatSync(fd).size;
const length = Math.min(size, maxBytes);
const start = size - length;
const buffer = Buffer.alloc(length);
readSync(fd, buffer, 0, length, start);
let text = buffer.toString("utf8");
if (start > 0) {
// The cut may be mid-line and even mid-character; drop that partial line
// so the tail never starts with a replacement character.
const firstBreak = text.indexOf("\n");
text =
firstBreak >= 0
? text.slice(firstBreak + 1)
: text.replace(/^\uFFFD+/, "");
}
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
return text
.split("\n")
.filter((line) => line.trim().length > 0)
.slice(-maxLines)
.join("\n");
} catch {
return "";
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {
// Already closed.
}
}
}
}
/** Spawn pip with stdout/stderr appended to a per-job log file. */
export function createInstallSpawner(
base: NodeJS.ProcessEnv = process.env,
): InstallSpawner {
return {
start({ exe, args, logPath }) {
const fd = openAppendLog(logPath);
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {
shell: false,
windowsHide: true,
// POSIX: lead a process group so a cancel can stop pip's build
// backends too, not only pip itself.
detached: process.platform !== "win32",
env: {
...base,
PYTHONIOENCODING: "utf-8",
PYTHONUTF8: "1",
PYTHONUNBUFFERED: "1",
},
stdio: ["ignore", fd, fd],
});
} finally {
// The child owns its inherited handle; the parent copy is no longer needed.
closeSync(fd);
}
return {
pid: child.pid,
onExit(callback) {
let settled = false;
const finish = (code: number | null): void => {
if (settled) return;
settled = true;
callback(code);
};
child.on("close", finish);
child.on("error", () => finish(null));
},
kill(force = false) {
if (child.pid !== undefined && process.platform === "win32") {
// Tree-kill: pip's build backends are child processes.
const killer = spawn(
"taskkill",
["/PID", String(child.pid), "/T", "/F"],
{ windowsHide: true, stdio: "ignore" },
);
// Without this listener an async spawn failure would exit the Host.
killer.on("error", () => undefined);
killer.unref();
return;
}
const signal = force ? "SIGKILL" : "SIGTERM";
// The install process leads its own group on POSIX, so pip's build
// backends receive the signal as well.
try {
if (child.pid !== undefined) process.kill(-child.pid, signal);
return;
} catch {
// Not a group leader (e.g. spawn failed); signal the process only.
}
try {
child.kill(signal);
} catch {
// Already gone.
}
},
groupAlive() {
return processGroupAlive(child.pid);
},
};
},
};
}
/**
* Whether two interpreter paths name the same interpreter.
*
* The path flavor decides the comparison: Windows paths fold case and
* separators, POSIX paths do not, because they are case-sensitive there. The
* tab compares the same way, so a job it found is never dropped again.
*/
function sameInterpreter(left: string, right: string): boolean {
return interpreterKey(left) === interpreterKey(right);
}
/**
* Default grace period between the graceful termination signal and the
* force-kill of a cancelled job. pip gets a moment to clean up its build
* children before it is killed outright.
*/
const DEFAULT_KILL_GRACE_MS = 5_000;
/**
* Default grace period for a disposed job.
*
* Zero: the plugin is unloading, so the force-kill follows the graceful signal
* immediately and a child can never outlive the Host.
*/
const DEFAULT_DISPOSE_GRACE_MS = 0;
/**
* How often a cancelled job whose group outlived its leader is re-checked.
* Small enough to settle promptly, large enough not to busy-wait.
*/
const DEFAULT_GROUP_POLL_MS = 100;
/**
* How long a cancelled job waits for a process group it already force-killed
* to empty. A group still populated after this stays unconfirmed (and locked)
* rather than being reported as a cancellation that did not happen.
*/
const DEFAULT_GROUP_TIMEOUT_MS = 5_000;
interface Job {
id: string;
phase: InstallPhase;
pythonPath: string;
command: string;
startedAt: string;
finishedAt?: string;
exitCode?: number | null;
logPath: string;
child: InstallChild;
/** Set between a termination request and the pip process actually exiting. */
cancelling: boolean;
/**
* Escalation timer that force-kills the child after the grace period.
*
* It never settles the job: only an observed exit does.
*/
terminateTimer?: ReturnType<typeof setTimeout>;
/**
* Poll that waits for the whole process group of a cancelled job to empty
* after its leader already exited. It settles the job once the group is
* gone; until then the interpreter stays locked.
*/
groupTimer?: ReturnType<typeof setTimeout>;
}
export interface InstallJobsOptions {
spawner: InstallSpawner;
logDir: string;
/**
* Grace period between the graceful termination signal and the force-kill
* of a cancelled job. Defaults to 5 s, so pip can clean up before it is
* force-killed.
*/
killGraceMs?: number;
/**
* Grace period between the graceful termination signal and the force-kill
* of a disposed job. Defaults to 0: the plugin is unloading, so the
* force-kill follows immediately and a child can never outlive the Host.
*/
disposeGraceMs?: number;
/**
* How often a cancelled job re-checks a process group whose leader already
* exited. Defaults to 100 ms.
*/
groupPollMs?: number;
/**
* How long a cancelled job waits for that group to empty after the
* force-kill. Defaults to 5 s; a group still populated afterwards keeps the
* job unconfirmed instead of reporting a completed cancellation.
*/
groupTimeoutMs?: number;
}
/**
* One long-running pip install at a time per interpreter. Jobs live in Host
* memory and write a log file, so closing the browser keeps them running and
* a running job can be restored by polling the latest status.
*/
export class InstallJobs {
private readonly jobs = new Map<string, Job>();
private latest: string | undefined;
private sequence = 0;
private readonly killGraceMs: number;
private readonly disposeGraceMs: number;
private readonly groupPollMs: number;
private readonly groupTimeoutMs: number;
constructor(private readonly options: InstallJobsOptions) {
this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
this.disposeGraceMs = options.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS;
this.groupPollMs = options.groupPollMs ?? DEFAULT_GROUP_POLL_MS;
this.groupTimeoutMs = options.groupTimeoutMs ?? DEFAULT_GROUP_TIMEOUT_MS;
}
start(request: InstallRequest): InstallStatus {
const pythonPath = (request.pythonPath ?? "").trim();
// The interpreter may be written for another platform than the host, so its
// own flavor decides whether the path is absolute.
if (!pythonPath || !isAbsolutePath(pythonPath))
throw new TypeError(
"Install requires an absolute Python interpreter path",
);
// The same lookup the status view uses: a job that is still running for
// this interpreter owns it, even when its termination is unconfirmed.
const running = this.runningFor(pythonPath);
if (running) return this.snapshot(running);
const args = pipArgs(request);
const id = `install-${Date.now().toString(
36,
)}-${(this.sequence += 1).toString(36)}`;
mkdirSync(this.options.logDir, { recursive: true });
const logPath = join(this.options.logDir, `${id}.log`);
const child = this.options.spawner.start({
exe: pythonPath,
args,
logPath,
});
const job: Job = {
id,
phase: "running",
pythonPath,
command: [pythonPath, ...args].join(" "),
startedAt: new Date().toISOString(),
logPath,
child,
cancelling: false,
};
this.jobs.set(id, job);
this.latest = id;
// Prune once the new log exists, so the steady state really is `keep`
// files rather than one more than the documented number.
pruneInstallLogs(
this.options.logDir,
INSTALL_LOG_KEEP,
[...this.jobs.values()]
.filter((running) => running.phase === "running")
.map((running) => running.logPath),
);
child.onExit((code) => this.finish(job, code));
return this.snapshot(job);
}
/**
* Status of one job, of the newest running job for an interpreter, or of the
* newest job overall.
*
* The interpreter lookup is what lets a tab restore the job it lost: two
* interpreters may install at the same time, so the newest job overall is
* not necessarily the one on screen.
*/
status(jobId?: string, pythonPath?: string): InstallStatus | null {
const trimmed = (jobId ?? "").trim();
if (trimmed) {
const job = this.jobs.get(trimmed);
return job ? this.snapshot(job) : null;
}
const wanted = (pythonPath ?? "").trim();
if (wanted) {
// Only a running job is restored: a finished one belongs to the tab that
// started it, which shows its own result for it.
const job = this.runningFor(wanted);
return job ? this.snapshot(job) : null;
}
const job = this.jobs.get(this.key(jobId));
return job ? this.snapshot(job) : null;
}
/** The newest running job for one interpreter, if there is one. */
private runningFor(pythonPath: string): Job | undefined {
let found: Job | undefined;
// The map keeps insertion order, so the last match is the newest job.
for (const job of this.jobs.values()) {
if (job.phase !== "running") continue;
if (!sameInterpreter(job.pythonPath, pythonPath)) continue;
found = job;
}
return found;
}
/**
* Ask pip to stop. The job keeps the interpreter locked (phase stays
* `running`, `cancelling` set) until the process has actually exited, so a
* second installation cannot start while the first is still dying.
*
* A process that ignores the graceful signal is force-killed after the
* grace period. The escalation never settles the job: only an observed
* exit does, so a failed termination stays visible instead of being
* reported as a completed cancellation.
*/
cancel(jobId?: string): boolean {
const job = this.jobs.get(this.key(jobId));
if (!job || job.phase !== "running" || job.cancelling) return false;
this.terminate(job, this.killGraceMs);
return true;
}
/**
* Stop every running installation; called when the plugin unloads.
*
* The jobs are not settled here: only an observed exit settles them. The
* default grace period is zero, so the force-kill follows the graceful
* signal immediately and a child that ignores it cannot outlive the Host.
*/
dispose(): void {
for (const job of this.jobs.values()) {
if (job.phase !== "running") continue;
this.terminate(job, this.disposeGraceMs);
}
}
/**
* The one termination path, shared by cancellation and disposal.
*
* It sends the graceful signal first and escalates to the force-kill after
* `graceMs`. It never settles the job: only an observed exit does — and, for
* a cancelled job whose leader exited first, an empty managed group — so the
* interpreter stays locked while any install process is still alive and a
* failed termination stays visible.
*/
private terminate(job: Job, graceMs: number): void {
this.clearTerminateTimer(job);
job.cancelling = true;
job.child.kill();
if (graceMs <= 0) {
// No grace period: the force-kill follows immediately, and nothing is
// left pending that could fire later or keep the Host alive.
job.child.kill(true);
return;
}
const timer = setTimeout(() => {
job.terminateTimer = undefined;
job.child.kill(true);
}, graceMs);
// A pending escalation must never keep the Host process alive.
timer.unref();
job.terminateTimer = timer;
}
/** Drop the pending escalation timer of a job. */
private clearTerminateTimer(job: Job): void {
if (!job.terminateTimer) return;
clearTimeout(job.terminateTimer);
job.terminateTimer = undefined;
}
private key(jobId?: string): string {
const trimmed = (jobId ?? "").trim();
return trimmed || this.latest || "";
}
/** Settle a running job exactly once, from an observed exit. */
private finish(job: Job, code: number | null): void {
if (job.phase !== "running") return;
this.clearTerminateTimer(job);
// A cancelled leader can exit while a build child that ignored the
// graceful signal keeps running. Only an empty group settles the job, so
// the interpreter stays locked until nothing of the install is left.
if (job.cancelling && this.groupAlive(job)) {
this.awaitGroupExit(job, code);
return;
}
this.settle(job, code);
}
/** Write the final phase of an already stopped job. */
private settle(job: Job, code: number | null): void {
this.clearTerminateTimer(job);
this.clearGroupTimer(job);
job.phase = job.cancelling
? "cancelled"
: code === 0
? "succeeded"
: "failed";
job.exitCode = code;
job.finishedAt = new Date().toISOString();
job.cancelling = false;
}
/** Whether the managed process group still holds a live process. */
private groupAlive(job: Job): boolean {
return job.child.groupAlive?.() === true;
}
/**
* Force-kill the group that outlived its leader, then settle only once it is
* empty.
*
* The job stays `running` (and the interpreter locked) for the whole wait,
* so a second installation cannot start while a build child still runs. A
* group that does not empty within the timeout keeps the job unconfirmed
* instead of reporting a cancellation that did not happen.
*
* The group id is the leader's pid, and the force-kill is sent in the short
* window right after its exit was observed; a pathological pid reuse inside
* that window is inherent to signalling a process group. The liveness probe
* uses signal 0, which also counts an unreaped zombie as present: the group
* keeps reading as non-empty until the zombie is reaped, so a dead group can
* take more than one poll to be seen as empty.
*/
private awaitGroupExit(job: Job, code: number | null): void {
this.clearGroupTimer(job);
job.child.kill(true);
const deadline = Date.now() + this.groupTimeoutMs;
const poll = (): void => {
job.groupTimer = undefined;
if (job.phase !== "running") return;
if (!this.groupAlive(job)) {
this.settle(job, code);
return;
}
if (Date.now() >= deadline) return;
job.groupTimer = this.after(this.groupPollMs, poll);
};
job.groupTimer = this.after(this.groupPollMs, poll);
}
/** Drop the pending group poll of a job. */
private clearGroupTimer(job: Job): void {
if (!job.groupTimer) return;
clearTimeout(job.groupTimer);
job.groupTimer = undefined;
}
/** Never let a pending wait keep the Host process alive. */
private after(
delayMs: number,
callback: () => void,
): ReturnType<typeof setTimeout> {
const timer = setTimeout(callback, delayMs);
timer.unref();
return timer;
}
/** Serialize only JSON-safe values; optional fields are omitted when unset. */
private snapshot(job: Job): InstallStatus {
return {
id: job.id,
phase: job.phase,
pythonPath: job.pythonPath,
command: job.command,
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
...(job.cancelling ? { cancelling: true } : {}),
logPath: job.logPath,
logTail: tailLog(job.logPath),
};
}
}

View file

@ -0,0 +1,74 @@
import {
mkdirSync,
openSync,
readdirSync,
renameSync,
rmSync,
statSync,
} from "node:fs";
import { basename, dirname, join } from "node:path";
/** Rotate a log once it grows past this size; only one backup is kept. */
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
/** Number of per-job install logs kept on disk. */
export const INSTALL_LOG_KEEP = 20;
/**
* Open a log for appending, rotating an oversized one to `<path>.1` first.
* Install and service logs otherwise grow without bound because both are
* append-only and outlive many sessions.
*/
export function openAppendLog(
path: string,
maxBytes = LOG_ROTATE_BYTES,
): number {
mkdirSync(dirname(path), { recursive: true });
try {
if (statSync(path).size > maxBytes) renameSync(path, `${path}.1`);
} catch {
// A missing log has nothing to rotate; a locked one (Windows, while the
// service still writes it) is left alone rather than failing the launch.
}
return openSync(path, "a");
}
/**
* Keep only the newest `keep` per-job logs. Each install writes a new file, so
* size-based rotation alone never bounds the directory.
*
* `active` are logs a running job is still writing; they are never removed.
* Each removal is attempted on its own, because one locked file (Windows) must
* not abort the rest of the housekeeping.
*/
export function pruneInstallLogs(
directory: string,
keep = INSTALL_LOG_KEEP,
active: readonly string[] = [],
): void {
try {
const keepSet = new Set(active.map((path) => basename(path)));
const entries = readdirSync(directory).filter(
(name) => name.startsWith("install-") && name.endsWith(".log"),
);
if (entries.length <= keep) return;
const byAge = entries
.map((name) => {
try {
return { name, mtime: statSync(join(directory, name)).mtimeMs };
} catch {
return { name, mtime: 0 };
}
})
.sort((left, right) => right.mtime - left.mtime);
for (const old of byAge.slice(keep)) {
if (keepSet.has(old.name)) continue;
try {
rmSync(join(directory, old.name), { force: true });
} catch {
// A log another job still holds open is retried next time.
}
}
} catch {
// Pruning is housekeeping; a missing directory is not an error.
}
}

View file

@ -0,0 +1,703 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { safeError } from "../../endpoint.js";
import { detectPythonEnvs } from "./detect.js";
import { envUpdates, storedState } from "../env-fields.js";
import { canHostService } from "../dependency.js";
import { isLocalEndpoint } from "../endpoint-rules.js";
import { createInstallSpawner, InstallJobs, tailLog } from "./install.js";
import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js";
import { createNodeRunner } from "./runner.js";
import {
createServiceIO,
forgetService,
processAlive,
readServiceState,
removeServiceState,
sameServiceRecord,
serviceEndpointKey,
serviceOriginKey,
serviceOwnership,
serviceRecordRef,
serviceStatus,
startService,
stopService,
writeServiceState,
} from "./service.js";
import type { PersistedService, ServiceIO } from "./service.js";
import type { ServiceOwnership } from "./service.js";
import {
envSummary,
mergeEnvFile,
parseEnvFile,
resolveWorkspaceDir,
testLlm,
validateEnvUpdate,
writeEnvText,
} from "./workspace.js";
import type {
ConnectionHealth,
EnvSummary,
InstallRequest,
InstallStatus,
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceRecordRef,
ServiceStartErrorCode,
ServiceStartResult,
ServiceState,
ServiceStopResult,
} from "../types.js";
const DEFAULT_ENDPOINT = "http://127.0.0.1:2333";
const CACHE_MS = 60_000;
/** How long a spawn is treated as "already starting" while it comes up. */
const START_GRACE_MS = 120_000;
/**
* Host-side environment manager: the one place that runs local operations and
* hands the results to the gateway.
*
* Everything here runs on the machine DSH runs on, so the tab and the Host can
* never disagree about which Python, which `.env` or which service "local"
* means. Lifetimes are explicit:
*
* - Python probes and reme-ai checks are bounded one-shot commands (`timeoutMs`).
* - A pip installation is a job in this manager's registry; {@link dispose}
* stops the ones still running when the plugin unloads, and its log survives
* in `$DSH_HOME/reme-memory`.
* - The ReMe service is detached on purpose: it must outlive DSH, so it is not
* handed to DSH's subprocess provider, which kills its children on Host exit.
* It is stopped only through the ownership-checked adapter in `service.ts`.
*/
export class ReMeEnvManager {
private cache?: { at: number; detection: PythonDetection };
private readonly installs: InstallJobs;
private readonly env: NodeJS.ProcessEnv;
private readonly serviceIO: ServiceIO;
private readonly serviceStatePath: string;
/** Serializes starts so two requests cannot spawn two services. */
private startQueue: Promise<unknown> = Promise.resolve();
private starting?: { endpoint: string; at: number; pid?: number };
constructor(
env: NodeJS.ProcessEnv = process.env,
serviceIO: ServiceIO = createServiceIO(),
) {
this.env = env;
this.serviceIO = serviceIO;
const home = env.DSH_HOME?.trim() || join(homedir(), ".dsh");
this.installs = new InstallJobs({
spawner: createInstallSpawner(env),
logDir: join(home, "reme-memory"),
});
this.serviceStatePath = join(home, "reme-memory", "service.json");
}
async detectPython(
options: { refresh?: boolean } = {},
): Promise<PythonDetection> {
if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS)
return this.cache.detection;
const detection = await detectPythonEnvs({
run: createNodeRunner(this.env),
platform: process.platform,
env: this.env,
homedir: homedir(),
exists: existsSync,
});
this.cache = { at: Date.now(), detection };
return detection;
}
async checkReme(pythonPath: string): Promise<RemeCheck> {
return checkReme(
{
run: createNodeRunner(this.env),
platform: process.platform,
exists: existsSync,
},
pythonPath,
);
}
install(request: InstallRequest): InstallStatus {
return this.installs.start(request);
}
/**
* Status of one job, of the newest running job for an interpreter, or of the
* newest job overall. The interpreter lookup is what lets a tab restore the
* job it lost: two interpreters may install at the same time, so "the latest
* job" is not necessarily the one on screen.
*/
installStatus(jobId?: string, pythonPath?: string): InstallStatus | null {
return this.installs.status(jobId, pythonPath);
}
installCancel(jobId?: string): boolean {
return this.installs.cancel(jobId);
}
/** Stop the installations this manager owns, on plugin disposal. */
dispose(): void {
this.installs.dispose();
}
/** Masked .env projection for the selected workspace. */
readEnv(workspaceDir: string): EnvSummary {
const dir = resolveWorkspaceDir(workspaceDir, homedir());
return envSummary(join(dir, ".env"), dir);
}
/**
* Merge edited values into the workspace .env. The whole merged file is
* validated, so an untouched real key still satisfies the requirement and a
* cleared one fails here rather than at the next service start.
*/
writeEnv(workspaceDir: string, values: Record<string, string>): EnvSummary {
const dir = resolveWorkspaceDir(workspaceDir, homedir());
const path = join(dir, ".env");
let existing = "";
try {
existing = readFileSync(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const stored = parseEnvFile(existing);
// The update rules are applied here as well as in the tab: a client that
// predates them must not be able to clear a stored credential by sending
// an empty value for it.
const merged = mergeEnvFile(
existing,
envUpdates(values, storedState(stored)),
);
validateEnvUpdate(parseEnvFile(merged));
writeEnvText(path, merged);
return envSummary(path, dir);
}
/** One-off LLM connectivity probe reading the workspace .env. */
async testLlm(
workspaceDir: string,
pythonPath: string,
): Promise<LlmTestResult> {
const dir = resolveWorkspaceDir(workspaceDir, homedir());
return testLlm(
{ run: createNodeRunner(this.env) },
{ workspaceDir: dir, pythonPath },
);
}
/** Whether the configured ReMe endpoint answers, probed from this Host. */
async health(endpoint: string, authToken = ""): Promise<ConnectionHealth> {
const endpointValue = this.endpoint(endpoint);
const health = await this.serviceIO.fetchHealth(endpointValue, authToken);
return {
endpoint: endpointValue,
ok: health.ok,
version: health.version,
error: health.error,
...(health.code === undefined ? {} : { code: health.code }),
checkedAt: new Date().toISOString(),
};
}
/** Current service phase (running / conflict / stopped) and ownership. */
async serviceStatus(endpoint: string, authToken = ""): Promise<ServiceState> {
const endpointValue = this.endpoint(endpoint);
const starting = this.starting;
const acknowledged = starting?.pid !== undefined;
// A record left under a path prefix of the same origin describes the
// service this endpoint talks to. It is re-keyed to the endpoint on screen
// first, so the tab can manage (stop) it and the start path can replace
// it instead of treating our own service as a foreign process.
await this.adoptLeftover(endpointValue);
const persisted = readServiceState(this.serviceStatePath);
// A malformed record must not turn the log path into a relative one.
let logDir = resolveWorkspaceDir("", homedir());
const recordedDir = persisted?.workspaceDir?.trim() ?? "";
if (recordedDir) {
try {
logDir = resolveWorkspaceDir(recordedDir, homedir());
} catch {
// Keep the default log directory.
}
}
const status = await serviceStatus(this.serviceIO, {
endpoint: endpointValue,
statePath: this.serviceStatePath,
authToken,
logPath: join(logDir, "service.log"),
});
if (
starting &&
this.starting === starting &&
starting.endpoint === endpointValue
) {
// Running and conflict both mean the port answered: the launch is no
// longer "in flight". Stopped without a live pid means it died before it
// ever listened, so a retry must not be blocked either. A stopped status
// *with* a pid is a service still booting.
if (
status.phase === "running" ||
status.phase === "conflict" ||
(acknowledged && status.phase === "stopped" && status.pid === undefined)
)
this.starting = undefined;
}
return status;
}
/**
* Spawn `reme start` detached; the caller polls serviceStatus. Starts are
* serialized and re-check the phase inside the lock, so two requests cannot
* spawn two services and overwrite the one recorded pid.
*/
serviceStart(
endpoint: string,
pythonPath: string,
workspaceDir: string,
authToken = "",
): Promise<ServiceStartResult> {
return this.enqueue(() =>
this.startLocked(endpoint, pythonPath, workspaceDir, authToken),
);
}
/**
* Stop the plugin-managed service; a foreign service stays untouched. Stop
* joins the start queue, so a request that arrives while a launch is still
* being recorded waits for it and then stops that launch instead of
* reporting "nothing started".
*/
serviceStop(endpoint: string): Promise<ServiceStopResult> {
const endpointValue = this.endpoint(endpoint);
return this.enqueue(() => this.stopLocked(endpointValue));
}
/**
* Drop a service record without touching the process. It is the escape hatch
* for a live process whose identity cannot be verified (Stop and Start both
* refuse to act on it). Joins the start queue so a launch that is still being
* recorded completes first.
*
* `expectPid` pins the record the caller saw: a record that was replaced in
* the meantime is refused instead of being dropped silently.
*/
serviceForget(
endpoint: string,
expectPid?: number,
): Promise<ServiceForgetResult> {
const endpointValue = this.endpoint(endpoint);
return this.enqueue(async () => {
const result = forgetService({
statePath: this.serviceStatePath,
endpoint: endpointValue,
...(expectPid === undefined ? {} : { expectPid }),
});
if (result.forgotten && this.starting?.endpoint === endpointValue)
this.starting = undefined;
return result;
});
}
/** Tail of the workspace service log. */
serviceLog(workspaceDir: string): ServiceLog {
const dir = resolveWorkspaceDir(workspaceDir, homedir());
const currentPath = join(dir, "service.log");
const current = tailLog(currentPath, 40);
if (current) return { logPath: currentPath, lines: current };
// Rotation moves the previous launch's crash output to `service.log.1`; if
// the current file is still empty, that backup is the only diagnostic. The
// returned path is the file actually read, so the UI labels it correctly.
const rotatedPath = `${currentPath}.1`;
const rotated = tailLog(rotatedPath, 40);
return rotated
? { logPath: rotatedPath, lines: rotated }
: { logPath: currentPath, lines: "" };
}
/** DSH-startup best effort: start a stopped local service when configured. */
async autoStart(options: {
endpoint: string;
pythonPath: string;
workspaceDir: string;
authToken?: string;
}): Promise<void> {
// Only a root-path loopback endpoint can become a local service. A
// configured gateway prefix (or a remote URL) is probed elsewhere, and a
// start could at best fail after stopping a same-origin service: skip it
// instead of reporting a failure on every DSH start.
let launchable = false;
try {
launchable = isLocalEndpoint(this.endpoint(options.endpoint));
} catch {
launchable = false;
}
if (!launchable) return;
const status = await this.serviceStatus(
options.endpoint,
options.authToken ?? "",
);
if (status.phase === "running") return;
let pythonPath = options.pythonPath.trim();
if (!pythonPath) {
const detection = await this.detectPython();
pythonPath = detection.suggested;
}
if (!pythonPath) return;
const result = await this.serviceStart(
options.endpoint,
pythonPath,
options.workspaceDir,
options.authToken ?? "",
);
if (!result.started && !result.alreadyRunning)
throw new Error(result.error || "Could not auto-start the ReMe service");
}
/**
* Serialize service operations: a start, a stop and a forget must not
* interleave, or two of them could act on the same record at once. A failed
* task must not wedge the queue, so it is swallowed here.
*/
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.startQueue.then(task);
this.startQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
/**
* A refused start. Every reason produces the same shape, so the client reads
* one result contract instead of guessing from the message.
*/
private refused(
logPath: string,
startedAt: string,
code: ServiceStartErrorCode,
error: string,
extra?: { pid?: number; record?: ServiceRecordRef },
): ServiceStartResult {
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt,
error,
code,
...(extra?.pid === undefined ? {} : { pid: extra.pid }),
...(extra?.record === undefined ? {} : { record: extra.record }),
};
}
private endpoint(raw: string): string {
const value = raw.trim() || DEFAULT_ENDPOINT;
let url: URL;
try {
url = new URL(value);
} catch {
throw new TypeError("Invalid ReMe endpoint");
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new TypeError("ReMe endpoint must be an http(s) URL");
if (url.username || url.password)
throw new TypeError("ReMe endpoint must not contain credentials");
if (url.search || url.hash)
throw new TypeError(
"ReMe endpoint must not contain a query string or fragment",
);
// The client appends job names to the endpoint, so it never keeps a
// trailing slash; normalising here is what makes records comparable.
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
}
private async stopLocked(endpointValue: string): Promise<ServiceStopResult> {
// A leftover record under a path prefix of the same origin is this
// service: it is re-keyed first, so a stop that arrives before any
// status read still acts on the right record instead of refusing it as
// foreign.
await this.adoptLeftover(endpointValue);
const result = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: endpointValue,
});
if (result.stopped && this.starting?.endpoint === endpointValue)
this.starting = undefined;
return result;
}
/**
* The recorded service and what can be proven about it.
*
* The record is read in one place and its ownership decided in one place,
* because the verdict drives three decisions: whether a start may replace
* the process, whether the record blocks it, and which record the tab may
* forget.
*
* Ownership is decided against the record's *own* endpoint: the launch
* configures only host and port, so a record filed under a path prefix
* still describes the service the endpoint on screen talks to.
*/
private async recordedService(): Promise<{
record?: PersistedService;
ownership: ServiceOwnership;
alive: boolean;
}> {
const record = readServiceState(this.serviceStatePath);
if (!record || record.pid === undefined)
return { ownership: "foreign", alive: false };
if (!processAlive(this.serviceIO, record.pid))
return { record, ownership: "foreign", alive: false };
return {
record,
alive: true,
ownership: await serviceOwnership(
this.serviceIO,
record,
record.endpoint,
),
};
}
/** Whether two endpoints name the same local service (host and port). */
private sameOrigin(recorded: string, endpoint: string): boolean {
return serviceOriginKey(recorded) === serviceOriginKey(endpoint);
}
/**
* Whether the recorded service is the one the endpoint on screen talks to:
* the same endpoint, or the same local service under another path.
*/
private relatedRecord(recorded: string, endpoint: string): boolean {
return (
serviceEndpointKey(recorded) === serviceEndpointKey(endpoint) ||
this.sameOrigin(recorded, endpoint)
);
}
/**
* Re-key a leftover same-origin record to the endpoint on screen.
*
* Only the endpoint the record is filed under changes: it stays the record
* of the same process, so the tab can stop it and the start path can replace
* it. A failed write is not fatal — the start path can still replace the
* record by its own endpoint.
*
* Only a record that is provably ours and describes the same local service
* is re-keyed: the same origin, a live pid, a Python image and a command
* line that carries the recorded `workspace_dir` and `service.port`. A
* request that cannot launch anything never qualifies: a prefixed request
* is refused by `buildStartCommand`, so stopping a healthy same-origin
* service first would leave nothing running (autoStart would do that on
* every DSH start).
*
* The record is compared and written without yielding in between: the
* ownership probe above takes long enough for a launch to record a new pid,
* and overwriting that record would orphan the fresh service. The compare
* and the write are synchronous, so nothing can slip in between them.
*/
private async adoptLeftover(endpointValue: string): Promise<void> {
if (!isLocalEndpoint(endpointValue)) return;
const { record, ownership } = await this.recordedService();
if (!record || ownership !== "owned") return;
// A record filed under the endpoint on screen is not a leftover.
if (
serviceEndpointKey(record.endpoint) === serviceEndpointKey(endpointValue)
)
return;
if (!this.sameOrigin(record.endpoint, endpointValue)) return;
const current = readServiceState(this.serviceStatePath);
if (!sameServiceRecord(current, record)) return;
try {
writeServiceState(this.serviceStatePath, {
...record,
endpoint: endpointValue,
});
} catch {
// The record keeps its old endpoint; the start path replaces it anyway.
}
}
private async startLocked(
endpoint: string,
pythonPath: string,
workspaceDir: string,
authToken: string,
): Promise<ServiceStartResult> {
const endpointValue = this.endpoint(endpoint);
const dir = resolveWorkspaceDir(workspaceDir, homedir());
const logPath = join(dir, "service.log");
const now = new Date().toISOString();
const starting = this.starting;
if (starting && Date.now() - starting.at < START_GRACE_MS) {
if (starting.endpoint === endpointValue)
// A genuine launch is in flight. Observing it is not an error: the
// client keeps polling just as the first request does.
return {
started: false,
alreadyRunning: true,
command: "",
logPath,
startedAt: now,
};
// The service record is per install, not per endpoint; spawning a second
// endpoint's service now would overwrite the first one's record and
// orphan it.
return this.refused(
logPath,
now,
"other-endpoint-starting",
`A service start for ${starting.endpoint} is still in progress`,
);
}
this.starting = undefined;
const current = await this.serviceStatus(endpointValue, authToken);
if (current.phase === "running")
return {
started: false,
alreadyRunning: true,
command: "",
logPath,
startedAt: now,
...(current.version ? { version: current.version } : {}),
};
const { record, ownership, alive } = await this.recordedService();
const replace =
record !== undefined &&
ownership === "owned" &&
isLocalEndpoint(endpointValue) &&
this.sameOrigin(record.endpoint, endpointValue);
// A port conflict is replaceable only when this record proves that our
// own service holds it. An unverified related record retains Forget.
if (
current.phase === "conflict" &&
!replace &&
!(
record &&
ownership === "unknown" &&
this.relatedRecord(record.endpoint, endpointValue)
)
)
return this.refused(
logPath,
now,
"conflict",
"Another process is already listening on this endpoint",
);
if (record && alive) {
if (ownership === "unknown")
return this.refused(
logPath,
now,
"unverified-record",
`The recorded service for ${record.endpoint} (pid ${record.pid}) could not be verified; leaving it untouched`,
{ pid: record.pid, record: serviceRecordRef(record) },
);
if (ownership === "owned" && !replace)
return this.refused(
logPath,
now,
"other-endpoint-running",
`A service for ${record.endpoint} is still running; stop it from that endpoint first`,
);
}
const path = pythonPath.trim();
if (!path)
return this.refused(
logPath,
now,
"no-python",
"Select a Python interpreter first",
);
const check = await this.checkReme(path);
// The same rule the tab uses to offer a start decides it here, so a
// button is never offered for an interpreter this refuses.
if (!canHostService(check)) {
const code = !check.installed
? "not-installed"
: !check.versionOk
? "version-too-old"
: "core-missing";
const errors = {
"not-installed": "reme-ai is not installed in the selected interpreter",
"version-too-old": `reme-ai ${
check.version || "(unknown version)"
} is older than ${MINIMUM_REME}; upgrade it first`,
"core-missing":
"AgentScope is required to start ReMe; install reme-ai[core] in the selected interpreter",
};
return this.refused(logPath, now, code, errors[code]);
}
const inspected = readServiceState(this.serviceStatePath);
if (!sameServiceRecord(inspected, record))
return this.refused(
logPath,
now,
"previous-not-stopped",
"The service record changed during the dependency check; retry with its current state",
{ record: serviceRecordRef(inspected) },
);
// All replacement cases share this path. Validate the new interpreter
// before stopping anything, then re-verify ownership at the stop boundary.
if (replace && record) {
const previous = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: record.endpoint,
expectPid: record.pid,
});
const remaining = readServiceState(this.serviceStatePath);
if (!previous.stopped || remaining)
return this.refused(
logPath,
now,
"previous-not-stopped",
`A previous launch could not be stopped: ${
previous.reason ?? "the service record changed while stopping"
}`,
{ record: previous.record ?? serviceRecordRef(remaining) },
);
} else if (record) {
// Only dead or provably foreign records reach here.
removeServiceState(this.serviceStatePath);
}
// Set before the spawn so a second request arriving while the service is
// booting observes the first launch instead of spawning another one. It is
// cleared again when the spawn fails, so a retry is possible immediately.
this.starting = { endpoint: endpointValue, at: Date.now() };
const launch = this.starting;
try {
const result = await startService(this.serviceIO, {
endpoint: endpointValue,
workspaceDir: dir,
pythonPath: path,
cliPath: check.cliPath,
// 0.4.0.x has no jobs.<name>.enabled; only newer releases accept it.
disableDreamCron: remeVersionAtLeast(check.version, "0.4.1"),
statePath: this.serviceStatePath,
logPath,
env: this.env,
});
if (!result.started) this.starting = undefined;
else if (result.pid !== undefined) launch.pid = result.pid;
return result;
} catch (error) {
this.starting = undefined;
return this.refused(
logPath,
now,
"start-failed",
`Could not start the ReMe service: ${safeError(error)}`,
);
}
}
}

View file

@ -0,0 +1,30 @@
import { posix, win32, type PlatformPath } from "node:path";
import { isWindowsPath } from "../interpreter-key.js";
export { isWindowsPath };
/**
* The path flavor a value is written in.
*
* Interpreter discovery and the probes around it see paths that may belong to
* the simulated platform, so they must not fall back to the host's
* `node:path`: the same fixtures then resolve identically on Windows, macOS and
* Linux instead of only on the machine that wrote them.
*/
export function pathOf(value: string): PlatformPath {
return isWindowsPath(value) ? win32 : posix;
}
/**
* Whether a path is absolute in its own platform's semantics.
*
* `win32.isAbsolute("\\python.exe")` is true, but such a path is relative to
* the current drive, which is not what the interpreter and install checks must
* accept. Windows therefore needs a drive letter or a UNC prefix.
*/
export function isAbsolutePath(value: string): boolean {
if (isWindowsPath(value))
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
return value.startsWith("/");
}

View file

@ -0,0 +1,144 @@
import { win32 } from "node:path";
import type { RemeCheck, RemeProbe } from "../types.js";
import { isAbsolutePath, pathOf } from "./paths.js";
import type { RunCommand } from "./runner.js";
/** Minimum reme-ai version this DSH integration is tested against. */
export const MINIMUM_REME = "0.4.0";
const REME_VERSION_SNIPPET =
"import importlib.metadata as m;print(m.version('reme-ai'))";
export interface RemeIO {
run: RunCommand;
platform: string;
exists(path: string): boolean;
}
/** Numeric dotted-version comparison; returns -1, 0 or 1. */
export function compareVersions(a: string, b: string): number {
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
const right = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const diff = (left[index] ?? 0) - (right[index] ?? 0);
if (diff !== 0) return Math.sign(diff);
}
return 0;
}
/** Strict numeric version gate; empty or prefixed versions never pass. */
export function remeVersionAtLeast(
version: string,
minimum: string = MINIMUM_REME,
): boolean {
const normalized = version.trim();
if (!/^\d+(?:\.\d+)+$/.test(normalized)) return false;
return compareVersions(normalized, minimum) >= 0;
}
/** Read a bare version line out of pip/import noise. */
export function parseRemeVersion(text: string): string {
const match = /^\s*(\d+(?:\.\d+)+)\s*$/m.exec(text);
return match?.[1] ?? "";
}
/** Ask one interpreter for its installed reme-ai metadata version. */
export async function probeReme(
io: RemeIO,
pythonPath: string,
): Promise<RemeProbe> {
try {
const result = await io.run(pythonPath, ["-c", REME_VERSION_SNIPPET], {
timeoutMs: 10_000,
});
if (result.error || result.code !== 0)
return {
installed: false,
version: "",
versionOk: false,
...(result.error ? { error: result.error } : {}),
};
const version = parseRemeVersion(result.stdout);
return {
installed: version !== "",
version,
versionOk: remeVersionAtLeast(version),
};
} catch (error) {
return {
installed: false,
version: "",
versionOk: false,
error: String(error),
};
}
}
/** Locate the console script installed beside the interpreter. */
export function remeCliPath(io: RemeIO, pythonPath: string): string {
// The interpreter path may be written for another platform than the host, so
// its flavor supplies the separators and joins.
const path = io.platform === "win32" ? win32 : pathOf(pythonPath);
const directory = path.dirname(pythonPath);
// venv interpreters live inside the scripts/bin directory; conda ones do not.
const scriptsDir =
io.platform === "win32"
? /[\\/]scripts$/i.test(directory)
? directory
: path.join(directory, "Scripts")
: directory;
const candidate = path.join(
scriptsDir,
io.platform === "win32" ? "reme.exe" : "reme",
);
return io.exists(candidate) ? candidate : "";
}
/**
* Full dependency check for one interpreter: reme-ai presence/version, the
* `[core]` AgentScope import, and the CLI entry used by service management.
*/
export async function checkReme(
io: RemeIO,
pythonPath: string,
): Promise<RemeCheck> {
const path = pythonPath.trim();
if (!path || !isAbsolutePath(path)) {
return {
installed: false,
version: "",
versionOk: false,
hasCore: false,
cliPath: "",
error: "Python interpreter path must be absolute",
};
}
const probe = await probeReme(io, path);
if (!probe.installed) {
return {
...probe,
versionOk: false,
hasCore: false,
cliPath: "",
error: probe.error ?? "",
};
}
let hasCore = false;
try {
const core = await io.run(path, ["-c", "import agentscope"], {
timeoutMs: 10_000,
});
hasCore = !core.error && core.code === 0;
} catch {
hasCore = false;
}
return {
...probe,
versionOk: remeVersionAtLeast(probe.version),
hasCore,
cliPath: remeCliPath(io, path),
error: "",
};
}

View file

@ -0,0 +1,129 @@
import { spawn } from "node:child_process";
/** Only ESRCH proves a detached POSIX process group is empty. */
export function processGroupAlive(pid: number | undefined): boolean {
if (
process.platform === "win32" ||
pid === undefined ||
!Number.isInteger(pid) ||
pid <= 1
)
return false;
try {
process.kill(-pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
}
export interface RunResult {
code: number | null;
stdout: string;
stderr: string;
/** Spawn or timeout failure description; absent on a normal exit. */
error?: string;
}
export interface RunOptions {
timeoutMs?: number;
cwd?: string;
/** Join argv verbatim (cmd.exe /s /c wrappers); never combine with user input. */
windowsVerbatimArguments?: boolean;
}
export type RunCommand = (
exe: string,
args: string[],
options?: RunOptions,
) => Promise<RunResult>;
/** UTF-8 guards so probe output survives GBK console codepages on Windows. */
export function probeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...base,
PYTHONIOENCODING: "utf-8",
PYTHONUTF8: "1",
};
}
/** Array-argv, shell-free runner with a hard timeout and no thrown rejections. */
export function createNodeRunner(
base: NodeJS.ProcessEnv = process.env,
): RunCommand {
return (exe, args, options = {}) =>
new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {
shell: false,
windowsHide: true,
...(options.windowsVerbatimArguments
? { windowsVerbatimArguments: true }
: {}),
...(options.cwd ? { cwd: options.cwd } : {}),
env: probeEnv(base),
});
} catch (error) {
resolve({ code: null, stdout: "", stderr: "", error: String(error) });
return;
}
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const timers: ReturnType<typeof setTimeout>[] = [];
const settle = (result: RunResult): void => {
if (settled) return;
settled = true;
for (const timer of timers) clearTimeout(timer);
resolve(result);
};
const timeoutMs = options.timeoutMs ?? 10_000;
const timer = setTimeout(() => {
// The caller must see an explicit error: an exit caused by our own
// timeout is not "the interpreter has no reme-ai".
timedOut = true;
child.kill();
// A child that ignores SIGTERM must not make the probe hang forever:
// force it and settle, rather than waiting for a close that may never
// arrive.
const grace = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// Already exited.
}
settle({
code: null,
stdout,
stderr,
error: `Command timed out after ${timeoutMs} ms`,
});
}, 1_000);
grace.unref();
timers.push(grace);
}, timeoutMs);
timer.unref();
timers.push(timer);
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
settle({ code: null, stdout, stderr, error: String(error) });
});
child.on("close", (code) => {
settle({
code,
stdout,
stderr,
...(timedOut
? { error: `Command timed out after ${timeoutMs} ms` }
: {}),
});
});
});
}

View file

@ -0,0 +1,992 @@
import { execFile, spawn } from "node:child_process";
import {
closeSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createConnection } from "node:net";
import { basename, dirname } from "node:path";
import { safeError } from "../../endpoint.js";
import { ReMeClient } from "../../reme/client.js";
import type { ReMeHealthResult } from "../../reme/types.js";
import { serviceEndpointKey, serviceOriginKey } from "../endpoint-key.js";
import { parseLocalEndpoint } from "../endpoint-rules.js";
import type {
HealthErrorCode,
ServiceForgetResult,
ServiceRecordRef,
ServiceStartResult,
ServiceState,
ServiceStopResult,
} from "../types.js";
import { openAppendLog } from "./log.js";
import { isWindowsPath } from "./paths.js";
import { processGroupAlive } from "./runner.js";
export { serviceEndpointKey, serviceOriginKey } from "../endpoint-key.js";
export { parseLocalEndpoint } from "../endpoint-rules.js";
/**
* The local ReMe service is a *detached* process: it outlives DSH, which is the
* point of starting it from here, so it is deliberately not owned by DSH's
* subprocess provider (that provider terminates its children on Host exit).
* This module is the small service adapter that owns that lifetime instead:
* it spawns the process, records the pid in a state file, verifies ownership
* before reporting or stopping it, and tree-kills it on request.
*/
export interface PersistedService {
pid?: number;
endpoint: string;
workspaceDir: string;
pythonPath: string;
command: string;
startedAt: string;
}
export interface ServiceHealth {
ok: boolean;
version: string;
/** Non-empty when the endpoint answered but not as a ReMe service. */
error: string;
/** Stable reason the client localizes instead of showing `error`. */
code?: HealthErrorCode;
}
export interface ServiceIO {
fetchHealth(endpoint: string, authToken: string): Promise<ServiceHealth>;
portOpen(host: string, port: number): Promise<boolean>;
spawnService(options: {
exe: string;
args: string[];
cwd: string;
logPath: string;
env: NodeJS.ProcessEnv;
}): Promise<{ pid: number | undefined }>;
/**
* Ask the process, and its process group on POSIX, to stop. `force`
* escalates to `SIGKILL` (always `/F` on Windows).
*/
killTree(pid: number, force?: boolean): void;
probePid(pid: number): void;
/** Whether the detached POSIX process group still exists after its leader exits. */
groupAlive?(pid: number): boolean;
/** OS image name of a pid, or "" when it cannot be read. */
processImage(pid: number): Promise<string>;
/** Full command line of a pid, or "" when it cannot be read. */
processCommandLine?(pid: number): Promise<string>;
exists(path: string): boolean;
platform: string;
}
/** A pid we are willing to signal: > 1, so kill(0)/kill(-1) is impossible. */
export function isValidPid(pid: unknown): pid is number {
return typeof pid === "number" && Number.isInteger(pid) && pid > 1;
}
/**
* Compose the `reme start` invocation. The working directory must be the
* workspace so ReMe's `.env` discovery (cwd plus five parents) finds the file
* the Environment tab writes.
*/
export function buildStartCommand(options: {
cliPath: string;
pythonPath: string;
workspaceDir: string;
endpoint: string;
disableDreamCron: boolean;
exists(path: string): boolean;
}): { exe: string; args: string[]; display: string } {
const { port } = parseLocalEndpoint(options.endpoint);
const args = [
"start",
`workspace_dir=${options.workspaceDir}`,
"service.host=127.0.0.1",
`service.port=${port}`,
// Keeps the rich logo from crashing GBK consoles in the background.
"enable_logo=false",
...(options.disableDreamCron ? ["jobs.dream_cron.enabled=false"] : []),
];
if (options.cliPath && options.exists(options.cliPath))
return {
exe: options.cliPath,
args,
display: `${options.cliPath} ${args.join(" ")}`,
};
// The console script is missing (e.g. a bare venv without Scripts entry):
// run the same CLI entry through the interpreter itself.
const wrapper = `import sys;sys.argv=${JSON.stringify([
"reme",
...args,
])};from reme.reme import main;sys.exit(main())`;
return {
exe: options.pythonPath,
args: ["-c", wrapper],
display: `${options.pythonPath} -c <reme wrapper>`,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Read a record defensively: a malformed file is "no record", not a crash. */
export function readServiceState(path: string): PersistedService | undefined {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
let value: unknown;
try {
value = JSON.parse(text);
} catch {
return undefined;
}
if (!isRecord(value)) return undefined;
const endpoint = typeof value.endpoint === "string" ? value.endpoint : "";
if (!endpoint.trim()) return undefined;
return {
...(isValidPid(value.pid) ? { pid: value.pid } : {}),
endpoint,
workspaceDir:
typeof value.workspaceDir === "string" ? value.workspaceDir : "",
pythonPath: typeof value.pythonPath === "string" ? value.pythonPath : "",
command: typeof value.command === "string" ? value.command : "",
startedAt: typeof value.startedAt === "string" ? value.startedAt : "",
};
}
/** Compare observed records before changing ownership metadata after an await. */
export function sameServiceRecord(
left: PersistedService | undefined,
right: PersistedService | undefined,
): boolean {
return (
left?.pid === right?.pid &&
left?.endpoint === right?.endpoint &&
left?.startedAt === right?.startedAt &&
left?.workspaceDir === right?.workspaceDir &&
left?.pythonPath === right?.pythonPath &&
left?.command === right?.command
);
}
export function writeServiceState(path: string, state: PersistedService): void {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, JSON.stringify(state, null, 2), {
encoding: "utf8",
mode: 0o600,
});
try {
renameSync(temporary, path);
} catch (error) {
rmSync(temporary, { force: true });
throw error;
}
}
/** Drop the service record without touching the recorded process. */
export function removeServiceState(path: string): void {
try {
rmSync(path, { force: true });
} catch {
// A stale state file is retried by the next status read.
}
}
/**
* Identity of a persisted record, for a result that names the record it could
* not act on. The recovery action targets this record, which is not
* necessarily the endpoint the request was made for.
*/
export function serviceRecordRef(
persisted: PersistedService | undefined,
): ServiceRecordRef | undefined {
if (!persisted) return undefined;
return {
endpoint: persisted.endpoint,
...(persisted.pid === undefined ? {} : { pid: persisted.pid }),
};
}
const clearServiceState = removeServiceState;
function alive(io: ServiceIO, pid?: number): boolean {
if (!isValidPid(pid)) return false;
try {
io.probePid(pid);
return true;
} catch (error) {
// Only ESRCH proves the process is gone. EPERM means it exists but this
// user may not signal it (for example a service DSH started elevated);
// treating that as dead would drop the record and orphan the process.
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
/** Whether the recorded pid or its detached process group still exists. */
export function processAlive(io: ServiceIO, pid?: number): boolean {
return isValidPid(pid) && (alive(io, pid) || io.groupAlive?.(pid) === true);
}
/**
* Whether an OS image name is a process that could be running the ReMe
* service: an exact Python-family name or `reme`, never a prefix match (which
* would accept e.g. `pycharm64.exe`).
*/
export function isServiceImage(image: string): boolean {
const name = basename(image.trim().replaceAll("\\", "/")).toLowerCase();
return /^(python|pythonw|reme)([0-9.]*)?(\.exe)?$/.test(name);
}
/**
* Ownership of the recorded process, as far as this plugin can prove it.
*
* - `owned`: endpoint, pid, image and command line all match the launch this
* plugin recorded.
* - `foreign`: at least one piece of evidence contradicts the record.
* - `unknown`: the process exists but its image or command line could not be
* read (localized `tasklist`, `ps` failure, missing permissions).
*
* Stop is destructive, so it only accepts `owned`; starting can discard an
* `unknown` record (without killing it) instead of being blocked forever.
*/
export type ServiceOwnership = "owned" | "foreign" | "unknown";
/**
* Compare paths and ports with separators normalized. Windows command lines
* escape quotes as `\"`, so that sequence is folded back before matching; case
* is only folded for Windows paths, because POSIX paths are case-sensitive.
*/
function normalizeCommandText(value: string, foldCase: boolean): string {
const text = value
.replaceAll('\\"', '"')
.replaceAll("\\\\", "\\")
.replaceAll("/", "\\");
return foldCase ? text.toLowerCase() : text;
}
/**
* The local port a recorded endpoint names, without the launch rules.
*
* `commandMatches` only needs the port to find `service.port=<port>` in the
* command line, and it must stay as permissive as the record is old: a record
* written before the root-path rule may name a prefixed loopback URL, and that
* process still has to be recognizable as the one this plugin started so it
* can be stopped. Reusing the launch rule here would turn such a live record
* into a `foreign` process that nothing owns.
*/
function recordedPort(endpoint: string): number | undefined {
let url: URL;
try {
url = new URL(endpoint.trim());
} catch {
return undefined;
}
const port = Number(url.port || (url.protocol === "https:" ? "443" : "80"));
if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
return port;
}
/**
* The recorded launch always passes `workspace_dir=<dir>` and
* `service.port=<port>` as argv entries (also inside the Python wrapper's
* `-c` script), so their presence is strong evidence that this pid is the
* process the record describes — not merely another Python that reused the pid.
*/
function commandMatches(command: string, persisted: PersistedService): boolean {
const port = recordedPort(persisted.endpoint);
if (port === undefined) return false;
const workspace = persisted.workspaceDir.trim();
if (!workspace) return false;
const foldCase = isWindowsPath(workspace);
const text = normalizeCommandText(command, foldCase);
const marker = `workspace_dir=${normalizeCommandText(workspace, foldCase)}`;
const index = text.indexOf(marker);
if (index < 0) return false;
// Require an argument boundary. A path separator is *not* one: the record
// `C:\a` must not match a launch for `C:\a\b` (POSIX `/repo` vs `/repo/sub`).
// The `\"` form Windows shows for the wrapper is folded by
// `normalizeCommandText`, so the quote itself is the boundary there.
const next = text[index + marker.length] ?? "";
if (next && !/[\s"',\]]/.test(next)) return false;
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
}
export async function serviceOwnership(
io: ServiceIO,
persisted: PersistedService | undefined,
endpoint: string,
): Promise<ServiceOwnership> {
if (!persisted || !isValidPid(persisted.pid)) return "foreign";
if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint))
return "foreign";
// A failed stop may leave children after the verified leader exited. Keep
// the record, but do not claim ownership we can no longer verify.
if (!alive(io, persisted.pid))
return processAlive(io, persisted.pid) ? "unknown" : "foreign";
let image = "";
try {
image = await io.processImage(persisted.pid);
} catch {
image = "";
}
if (!image.trim()) return "unknown";
if (!isServiceImage(image)) return "foreign";
let command = "";
try {
command = (await io.processCommandLine?.(persisted.pid)) ?? "";
} catch {
command = "";
}
if (!command.trim()) return "unknown";
return commandMatches(command, persisted) ? "owned" : "foreign";
}
/**
* Whether the recorded process is provably the service this plugin started
* for the endpoint on screen.
*
* This guards a destructive operation, so it fails *closed*: unreadable
* evidence is not proof of ownership.
*/
export async function ownsService(
io: ServiceIO,
persisted: PersistedService | undefined,
endpoint: string,
): Promise<boolean> {
return (await serviceOwnership(io, persisted, endpoint)) === "owned";
}
/** Running / conflict / stopped, with ownership of the running process. */
export async function serviceStatus(
io: ServiceIO,
options: {
endpoint: string;
statePath: string;
logPath: string;
authToken?: string;
},
): Promise<ServiceState> {
const health = await io.fetchHealth(
options.endpoint,
options.authToken ?? "",
);
const persisted = readServiceState(options.statePath);
const recordForEndpoint =
persisted !== undefined &&
serviceEndpointKey(persisted.endpoint) ===
serviceEndpointKey(options.endpoint);
const recordAlive = processAlive(io, persisted?.pid);
// A record whose process is gone must not be reported, or a later start
// would keep seeing a stale pid. Remove it while nobody can be relying on it.
if (persisted && !recordAlive) clearServiceState(options.statePath);
// Ownership is expensive (image plus command line). During boot the service
// is polled every second while `stopped`, where it is not needed, so it is
// computed lazily only for the phases that report it.
const ownership = async (): Promise<ServiceOwnership> =>
recordForEndpoint && recordAlive
? serviceOwnership(io, persisted, options.endpoint)
: "foreign";
/**
* The record behind the status, when the Host cannot prove that the process
* is the one the record describes.
*
* An unreadable identity is no proof, so the process is never signalled —
* but the record is what the tab has to drop to get the port back, and it
* has to travel with the status: the conflict card cannot offer a recovery
* that only a refused start would name.
*
* A record for another path on the same origin describes the same local
* service, so it is related too.
*/
const unverified = async (
owner: ServiceOwnership,
): Promise<ServiceRecordRef | undefined> => {
if (!persisted || !recordAlive) return undefined;
if (owner === "unknown") return serviceRecordRef(persisted);
if (recordForEndpoint) return undefined;
if (
serviceOriginKey(persisted.endpoint) !==
serviceOriginKey(options.endpoint)
)
return undefined;
const other = await serviceOwnership(io, persisted, persisted.endpoint);
return other === "unknown" ? serviceRecordRef(persisted) : undefined;
};
if (health.ok) {
const owner = await ownership();
const managed = owner === "owned";
const record = await unverified(owner);
return {
phase: "running",
managed,
endpoint: options.endpoint,
...(managed && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
...(health.version ? { version: health.version } : {}),
...(managed && persisted ? { startedAt: persisted.startedAt } : {}),
...(record ? { record } : {}),
logPath: options.logPath,
};
}
let portOpen = false;
try {
const { host, port } = parseLocalEndpoint(options.endpoint);
portOpen = await io.portOpen(host, port);
} catch {
// A remote or https endpoint is health-checked only; it has no local port.
}
if (portOpen) {
// Our own service can occupy the port without being healthy (still
// booting, or a rejected token); identify it so the tab can stop it.
const owner = await ownership();
const managed = owner === "owned";
const record = await unverified(owner);
return {
phase: "conflict",
managed,
endpoint: options.endpoint,
...(managed && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
...(record ? { record } : {}),
logPath: options.logPath,
};
}
return {
phase: "stopped",
managed: false,
endpoint: options.endpoint,
// A live record for this endpoint means a launch that never became
// healthy; the manager stops or discards it before spawning a replacement.
...(recordForEndpoint && recordAlive && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
logPath: options.logPath,
};
}
/**
* Spawn a detached service; the caller polls status until it is healthy. The
* launch is recorded only once the OS acknowledged the spawn, so a missing
* interpreter leaves no state file behind for a later tab to adopt.
*/
export async function startService(
io: ServiceIO,
options: {
endpoint: string;
workspaceDir: string;
pythonPath: string;
cliPath: string;
disableDreamCron: boolean;
statePath: string;
logPath: string;
env: NodeJS.ProcessEnv;
},
): Promise<ServiceStartResult> {
const startedAt = new Date().toISOString();
let command: { exe: string; args: string[]; display: string };
try {
mkdirSync(options.workspaceDir, { recursive: true });
command = buildStartCommand({
cliPath: options.cliPath,
pythonPath: options.pythonPath,
workspaceDir: options.workspaceDir,
endpoint: options.endpoint,
disableDreamCron: options.disableDreamCron,
exists: io.exists,
});
} catch (error) {
return {
started: false,
alreadyRunning: false,
command: "",
logPath: options.logPath,
startedAt,
error: `Could not start the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
let child: { pid: number | undefined };
try {
child = await io.spawnService({
exe: command.exe,
args: command.args,
cwd: options.workspaceDir,
logPath: options.logPath,
env: {
...options.env,
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
NO_PROXY: "localhost,127.0.0.1,::1",
},
});
} catch (error) {
return {
started: false,
alreadyRunning: false,
command: command.display,
logPath: options.logPath,
startedAt,
error: `Could not start the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
try {
// Another manager may have published a launch while spawn acknowledgement
// was pending. Keep its record and terminate only our unrecorded child.
if (readServiceState(options.statePath))
throw new Error(
"Another service was recorded while this launch was starting",
);
writeServiceState(options.statePath, {
pid: child.pid,
endpoint: options.endpoint,
workspaceDir: options.workspaceDir,
pythonPath: options.pythonPath,
command: command.display,
startedAt,
});
} catch (error) {
// The process exists but could not be recorded; leaving it running would
// make it unmanageable, so stop it before reporting the failure.
let exited = child.pid === undefined;
if (child.pid !== undefined) {
try {
io.killTree(child.pid, true);
} catch {
// Still confirm whether it exited before reporting the failure.
}
exited = await waitForExit(
io,
child.pid,
STOP_FORCE_TIMEOUT_MS,
STOP_POLL_MS,
);
}
return {
started: false,
alreadyRunning: false,
command: command.display,
logPath: options.logPath,
startedAt,
error: `Could not record the ReMe service: ${safeError(error)}${
exited ? "" : `; process ${child.pid} could not be confirmed stopped`
}`,
...(!exited && child.pid !== undefined ? { pid: child.pid } : {}),
code: "start-failed",
};
}
return {
started: true,
alreadyRunning: false,
...(child.pid === undefined ? {} : { pid: child.pid }),
command: command.display,
logPath: options.logPath,
startedAt,
};
}
const STOP_TIMEOUT_MS = 5_000;
const STOP_FORCE_TIMEOUT_MS = 3_000;
const STOP_POLL_MS = 100;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** Wait for both the leader and its detached process group to disappear. */
async function waitForExit(
io: ServiceIO,
pid: number,
timeoutMs: number,
pollMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (!processAlive(io, pid)) return true;
const remaining = deadline - Date.now();
if (remaining <= 0) return false;
await delay(Math.max(1, Math.min(pollMs, remaining)));
}
}
/**
* Stop the service this plugin started for the endpoint, and only that one: a
* service on another port, or a foreign process, is left running.
*
* The record is removed only after the process group is confirmed gone. Otherwise a
* SIGTERM the service ignored would delete the pid, leaving a running process
* that the tab can never stop again.
*/
export async function stopService(
io: ServiceIO,
options: {
statePath: string;
endpoint: string;
/** Pin a replacement to the process inspected before dependency checks. */
expectPid?: number;
/** Grace period before escalating to SIGKILL (tests shorten it). */
timeoutMs?: number;
forceTimeoutMs?: number;
pollMs?: number;
},
): Promise<ServiceStopResult> {
const persisted = readServiceState(options.statePath);
if (!persisted || !isValidPid(persisted.pid)) {
if (persisted) clearServiceState(options.statePath);
return {
stopped: false,
reason: "No service started by this plugin",
code: "no-record",
};
}
if (options.expectPid !== undefined && persisted.pid !== options.expectPid)
return {
stopped: false,
reason: "The recorded service changed since it was inspected",
code: "record-changed",
record: serviceRecordRef(persisted),
};
const ownership = await serviceOwnership(io, persisted, options.endpoint);
if (ownership === "unknown")
return {
stopped: false,
reason:
"The recorded process identity could not be verified; refusing to stop it",
code: "unverified",
record: serviceRecordRef(persisted),
};
if (ownership !== "owned")
return {
stopped: false,
reason: "The recorded service does not belong to this endpoint",
code: "foreign",
};
const pid = persisted.pid;
const isCurrentRecord = (): boolean => {
const current = readServiceState(options.statePath);
return sameServiceRecord(current, persisted);
};
// Identity reads yield to the event loop. Never signal a record that was
// replaced while those reads were in flight, or erase a newer launch later.
if (!isCurrentRecord())
return {
stopped: false,
pid,
reason: "The recorded service changed since it was inspected",
code: "record-changed",
record: serviceRecordRef(readServiceState(options.statePath)),
};
const pollMs = options.pollMs ?? STOP_POLL_MS;
try {
io.killTree(pid);
} catch {
// Already exited.
}
if (
await waitForExit(io, pid, options.timeoutMs ?? STOP_TIMEOUT_MS, pollMs)
) {
if (isCurrentRecord()) clearServiceState(options.statePath);
return { stopped: true, pid };
}
// The service ignored (or slowly handled) SIGTERM: escalate, then only drop
// the record once the process is really gone.
try {
io.killTree(pid, true);
} catch {
// Already exited.
}
if (
await waitForExit(
io,
pid,
options.forceTimeoutMs ?? STOP_FORCE_TIMEOUT_MS,
pollMs,
)
) {
if (isCurrentRecord()) clearServiceState(options.statePath);
return { stopped: true, pid };
}
return {
stopped: false,
pid,
reason: "The service did not exit after the force-kill",
code: "not-exited",
record: serviceRecordRef(persisted),
};
}
/**
* Drop the service record without signalling any process.
*
* Stop and Start both fail closed when ownership cannot be proven. With a live
* but unverifiable process (for example one started by an elevated DSH) that
* leaves the tab with no working action at all, so forgetting the record is the
* explicit way out: the plugin stops managing the process and the user confirms
* what to do with it themselves.
*
* `expectPid` pins the record the caller saw. Without it a record that was
* replaced in the meantime would be dropped silently; with it the request is
* refused instead, because the process it was meant to describe is a different
* one now.
*/
export function forgetService(options: {
statePath: string;
endpoint: string;
/** Pid the caller saw; the record must still describe it. */
expectPid?: number;
}): ServiceForgetResult {
const persisted = readServiceState(options.statePath);
if (!persisted || !isValidPid(persisted.pid)) {
if (persisted) clearServiceState(options.statePath);
return {
forgotten: false,
reason: "No service started by this plugin",
code: "no-record",
};
}
if (
serviceEndpointKey(persisted.endpoint) !==
serviceEndpointKey(options.endpoint)
)
return {
forgotten: false,
reason: "The recorded service does not belong to this endpoint",
code: "foreign",
};
if (options.expectPid !== undefined && persisted.pid !== options.expectPid)
return {
forgotten: false,
pid: persisted.pid,
reason: "The recorded service changed since it was inspected",
code: "record-changed",
record: serviceRecordRef(persisted),
};
clearServiceState(options.statePath);
return { forgotten: true, pid: persisted.pid };
}
/** How long the Host waits for a health answer before giving up. */
const HEALTH_TIMEOUT_MS = 3_000;
/**
* The Host's health verdict, taken from the shared ReMe client.
*
* The client already knows how to reach a ReMe service, how to authenticate
* against it and how to tell one from a generic HTTP server, so the Host
* reuses it instead of repeating that request logic. The stable codes stay:
* the tab localizes them instead of showing an English sentence.
*/
function healthFromResult(result: ReMeHealthResult): ServiceHealth {
const status = result.status ?? 0;
if (status === 0)
return {
ok: false,
version: "",
error: result.error || "The endpoint could not be reached",
code: "unreachable",
};
if (!result.ok) {
// The client reports an HTTP failure and a ReMe-reported error the same
// way; only the status tells them apart.
const code = status >= 200 && status < 300 ? "reme-error" : "http-status";
return {
ok: false,
version: "",
error:
code === "http-status" ? `HTTP ${status}` : "ReMe reported an error",
code,
};
}
// A 200 response is not a healthy ReMe: the health report has to parse.
if (!result.health)
return {
ok: false,
version: "",
error: "The endpoint did not answer with a ReMe health report",
code: "not-reme",
};
return { ok: true, version: result.health.version, error: "" };
}
/** Node-backed IO: fetch health, probe the port, spawn detached, tree-kill. */
export function createServiceIO(): ServiceIO {
return {
async fetchHealth(endpoint, authToken) {
const client = new ReMeClient({
endpoint,
requestTimeoutMs: HEALTH_TIMEOUT_MS,
backgroundTimeoutMs: HEALTH_TIMEOUT_MS,
...(authToken ? { authToken } : {}),
});
return healthFromResult(await client.healthCheck());
},
portOpen(host, port) {
return new Promise((resolve) => {
const socket = createConnection({ host, port });
const finish = (value: boolean): void => {
socket.destroy();
resolve(value);
};
socket.setTimeout(800);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
},
async spawnService({ exe, args, cwd, logPath, env }) {
const fd = openAppendLog(logPath);
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(exe, args, {
shell: false,
windowsHide: true,
detached: true,
cwd,
env,
stdio: ["ignore", fd, fd],
});
const started = child;
await new Promise<void>((resolve, reject) => {
// A spawn acknowledgement that never arrives must not block the
// start queue (and therefore a queued Stop) forever.
const timer = setTimeout(() => {
try {
started.kill();
} catch {
// Already gone.
}
reject(new Error("spawn did not acknowledge within 10 s"));
}, 10_000);
timer.unref();
started.once("spawn", () => {
clearTimeout(timer);
resolve();
});
started.once("error", (error: Error) => {
clearTimeout(timer);
reject(error);
});
});
} finally {
// The child owns its inherited handle; the parent copy is no longer
// needed. The listener and `unref()` must run on the timeout path too:
// otherwise a late acknowledgement keeps the child referenced and its
// `error` event unhandled, and the Host cannot exit.
closeSync(fd);
if (child !== undefined) {
child.on("error", () => undefined);
child.unref();
}
}
return { pid: child?.pid };
},
killTree(pid, force = false) {
if (!isValidPid(pid)) return;
if (process.platform === "win32") {
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
// A spawn failure is an async event; without a listener it would exit
// the Host.
child.on("error", () => undefined);
child.unref();
return;
}
// The service was spawned detached, so it leads its own process group;
// signalling the group stops pip's children too.
const signal = force ? "SIGKILL" : "SIGTERM";
try {
process.kill(-pid, signal);
return;
} catch {
// Not a group leader (e.g. a record from an older launch).
}
try {
process.kill(pid, signal);
} catch {
// Already exited.
}
},
probePid(pid) {
if (!isValidPid(pid))
throw new RangeError("Refusing to probe an invalid pid");
process.kill(pid, 0);
},
groupAlive: processGroupAlive,
processImage(pid) {
return new Promise((resolve) => {
if (!isValidPid(pid)) {
resolve("");
return;
}
if (process.platform === "win32") {
execFile(
"tasklist",
["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
{ windowsHide: true, timeout: 3000 },
(error, stdout) => {
const match = /^"([^"]+)"/.exec(String(stdout ?? "").trim());
resolve(error ? "" : match?.[1] ?? "");
},
);
return;
}
execFile(
"ps",
["-p", String(pid), "-o", "comm="],
{ timeout: 3000 },
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
);
});
},
processCommandLine(pid) {
return new Promise((resolve) => {
if (!isValidPid(pid)) {
resolve("");
return;
}
if (process.platform === "win32") {
// wmic is absent from recent Windows builds, so ask CIM. The encoding
// prefix keeps non-ASCII workspace paths intact through the pipe.
execFile(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
`[Console]::OutputEncoding=[Text.Encoding]::UTF8; (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`,
],
{ windowsHide: true, timeout: 5000 },
(error, stdout) =>
resolve(error ? "" : String(stdout ?? "").trim()),
);
return;
}
execFile(
"ps",
["-p", String(pid), "-o", "args="],
{ timeout: 3000 },
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
);
});
},
exists: existsSync,
platform: process.platform,
};
}

View file

@ -0,0 +1,504 @@
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { safeError } from "../../endpoint.js";
import { MANAGED_KEYS, SECRET_KEYS } from "../env-fields.js";
import { DEFAULT_LLM_BACKEND, llmBackendOf } from "../llm-backend.js";
import type { EnvSummary, LlmTestResult } from "../types.js";
import type { RunCommand } from "./runner.js";
/** Empty means "~/.reme"; "~" expands against home; relative paths are rejected. */
export function resolveWorkspaceDir(raw: string, home: string): string {
const trimmed = (raw ?? "").trim();
const expanded = trimmed.startsWith("~")
? join(home, trimmed.slice(1).replace(/^[\\/]+/, ""))
: trimmed;
const value = expanded || join(home, ".reme");
if (!isAbsolute(value))
throw new TypeError("ReMe workspace directory must be an absolute path");
return resolve(value);
}
/** Parse KEY=VALUE lines; comments, blanks, quotes and unknown keys are tolerated. */
export function parseEnvFile(text: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const equals = trimmed.indexOf("=");
if (equals <= 0) continue;
const key = trimmed.slice(0, equals).trim();
let value = trimmed.slice(equals + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
)
value = value.slice(1, -1);
entries[key] = value;
}
return entries;
}
/**
* Rewrite only the lines whose keys appear in updates; every other line
* (comments, unknown variables, ordering) is preserved byte-for-byte, and the
* file keeps its own line ending.
*
* A key that appears more than once is rewritten in every position. Parsing
* keeps the last occurrence, so rewriting only the first would leave an older
* value in force: after this function every duplicate agrees.
*/
export function mergeEnvFile(
existingText: string,
updates: Record<string, string>,
): string {
const pending = new Map(Object.entries(updates));
for (const [key, value] of pending) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
throw new TypeError("Invalid environment variable name");
if (typeof value !== "string" || /[\r\n\0]/.test(value))
throw new TypeError("Environment values must be single-line strings");
}
const eol = /\r\n/.test(existingText) ? "\r\n" : "\n";
const lines = existingText.length ? existingText.split(/\r?\n/) : [];
const output: string[] = [];
const written = new Set<string>();
for (const line of lines) {
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
const key = match?.[1] ?? "";
if (key && pending.has(key)) {
// Every occurrence takes the new value: parsing keeps the last one, so
// rewriting only the first would leave an older value in force.
output.push(`${key}=${pending.get(key) ?? ""}`);
written.add(key);
continue;
}
output.push(line);
}
// Normalize the trailing newline before appending new keys.
while (output.length && output[output.length - 1] === "") output.pop();
for (const [key, value] of pending)
if (!written.has(key)) output.push(`${key}=${value}`);
return `${output.join(eol)}${eol}`;
}
/** "••••" plus the last four characters; empty values stay empty. */
export function maskSecret(value: string): string {
if (!value) return "";
return value.length > 4 ? `••••${value.slice(-4)}` : "••••";
}
/**
* Read-only projection for the tab: secrets are never returned in clear.
*
* The managed keys and their order come from the shared metadata, so the Host
* projection and the browser form list the same keys in the same order.
*/
export function envSummary(path: string, workspaceDir: string): EnvSummary {
let text = "";
let exists = false;
try {
text = readFileSync(path, "utf8");
exists = true;
} catch {
// A missing .env is the valid first-run state.
}
const parsed = parseEnvFile(text);
const entries = MANAGED_KEYS.map((key) => {
const value = parsed[key] ?? "";
const secret = SECRET_KEYS.has(key);
return {
key,
secret,
set: value !== "",
masked: secret ? maskSecret(value) : value,
value: secret ? "" : value,
};
});
return { workspaceDir, envPath: path, exists, entries };
}
/**
* Reject a .env that cannot start ReMe. Version 0.4.0.x instantiates the
* embedding component unconditionally, so an empty key aborts startup.
*/
export function validateEnvUpdate(values: Record<string, string>): void {
if (!(values["LLM_API_KEY"] ?? "").trim())
throw new TypeError("LLM_API_KEY is required");
if (!(values["EMBEDDING_API_KEY"] ?? "").trim())
throw new TypeError(
"EMBEDDING_API_KEY must be non-empty (use EMPTY when vector search is disabled)",
);
}
/**
* Write a fully merged `.env`. The caller merges and validates first, so the
* file on disk is exactly what was validated.
*
* The write is atomic (same-directory temp file plus rename) and keeps the
* previous file as `.env.bak`: a crash or a full disk must not take unrelated
* keys such as `LLM_API_KEY` with it. New files are owner-only (0600) because
* they hold credentials.
*/
export function writeEnvText(path: string, text: string): void {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, text, { encoding: "utf8", mode: 0o600 });
try {
if (existsSync(path)) {
const backup = `${path}.bak`;
copyFileSync(path, backup);
// The backup holds the same credentials, so it must not be more readable
// than the file it preserves.
try {
chmodSync(backup, 0o600);
} catch {
// Windows has no POSIX mode bits; the copy is still user-owned.
}
}
renameSync(temporary, path);
} catch (error) {
rmSync(temporary, { force: true });
throw error;
}
}
/**
* How one provider expects to be called.
*
* The tab offers native Anthropic, Gemini and Ollama backends next to the
* OpenAI-compatible ones, so the probe has to speak each provider's own
* protocol: an OpenAI-style request would report a working provider-native
* configuration as broken.
*/
export interface LlmProtocol {
/** Path appended to the base URL; `{model}` is the model name. */
path: string;
/** Header that carries the API key; empty when the provider needs none. */
authHeader: string;
/** Value prefix for the key header, e.g. `Bearer `. */
authPrefix: string;
/** Headers every request carries. */
headers: Record<string, string>;
/** JSON body template; `{model}` is the model name. */
body: string;
/** Whether the provider needs an API key at all. */
keyRequired: boolean;
/**
* Where the base URL comes from.
*
* ReMe's credentials decide this. The OpenAI-compatible providers and
* Anthropic read `LLM_BASE_URL` from the workspace `.env`, so the probe
* reads it too. Gemini, Ollama and xAI ignore that variable — their
* credentials carry their own endpoint — so the probe must use that one as
* well: a stale value left in the `.env` would otherwise send the probe to
* a host the service never uses, and report a working configuration as
* broken.
*/
baseSource: "env" | "provider";
/** The provider's own endpoint; used when `baseSource` is `provider`. */
providerBase?: string;
}
/**
* The OpenAI-compatible chat request, shared by the compatible providers.
*
* `{model}` is replaced with the JSON-quoted model name, so the placeholder
* carries no quotes of its own.
*/
const OPENAI_CHAT_BODY =
'{"model":{model},"messages":[{"role":"user","content":"ping"}],"max_tokens":1}';
/** The protocol ReMe itself falls back to when `.env` names no backend. */
const OPENAI_PROTOCOL: LlmProtocol = {
path: "/chat/completions",
authHeader: "Authorization",
authPrefix: "Bearer ",
headers: {},
body: OPENAI_CHAT_BODY,
keyRequired: true,
baseSource: "env",
};
/** One entry per backend the tab offers; the key is the `.env` value. */
export const LLM_PROTOCOLS: Record<string, LlmProtocol> = {
openai: OPENAI_PROTOCOL,
dashscope: OPENAI_PROTOCOL,
deepseek: OPENAI_PROTOCOL,
moonshot: OPENAI_PROTOCOL,
anthropic: {
path: "/v1/messages",
authHeader: "x-api-key",
authPrefix: "",
headers: { "anthropic-version": "2023-06-01" },
body: '{"model":{model},"max_tokens":1,"messages":[{"role":"user","content":"ping"}]}',
keyRequired: true,
baseSource: "env",
},
gemini: {
path: "/v1beta/models/{model}:generateContent",
authHeader: "x-goog-api-key",
authPrefix: "",
headers: {},
body: '{"contents":[{"role":"user","parts":[{"text":"ping"}]}],"generationConfig":{"maxOutputTokens":1}}',
keyRequired: true,
baseSource: "provider",
providerBase: "https://generativelanguage.googleapis.com",
},
ollama: {
path: "/api/chat",
authHeader: "",
authPrefix: "",
headers: {},
body: '{"model":{model},"messages":[{"role":"user","content":"ping"}],"stream":false}',
keyRequired: false,
baseSource: "provider",
providerBase: "http://localhost:11434",
},
xai: {
// ReMe reaches xAI through its gRPC SDK, so this OpenAI-compatible
// endpoint is a connectivity approximation for that backend. It is still
// the documented REST API of the same provider and the same key, and it
// keeps the probe from reporting a working configuration as broken.
path: "/chat/completions",
authHeader: "Authorization",
authPrefix: "Bearer ",
headers: {},
body: OPENAI_CHAT_BODY,
keyRequired: true,
baseSource: "provider",
providerBase: "https://api.x.ai/v1",
},
};
/** The backend ReMe itself falls back to when `.env` names none. */
export { DEFAULT_LLM_BACKEND };
/**
* The protocol entry the table holds for `key`, if it holds one at all.
*
* Only an own entry counts: the table inherits from `Object.prototype`, so a
* plain lookup would hand out `Object` for a backend named `constructor` and
* build a malformed probe from it.
*/
function ownProtocol(key: string): LlmProtocol | undefined {
return Object.hasOwn(LLM_PROTOCOLS, key) ? LLM_PROTOCOLS[key] : undefined;
}
/**
* The protocol a backend speaks; an unknown backend uses the OpenAI-style
* request.
*
* The list of backends here is a snapshot of the ones the tab offers, and
* ReMe may support more, so an unknown name is probed tolerantly rather than
* refused.
*/
export function llmProtocolFor(backend: string): LlmProtocol {
return ownProtocol(llmBackendOf(backend)) ?? OPENAI_PROTOCOL;
}
/**
* Whether the file names a backend in a spelling ReMe will not have.
*
* ReMe's backend registry is case-sensitive, so a name that differs from a
* known backend only in case is a misspelling the service refuses
* ("Unregistered backend"). A name the plugin has never heard of is left
* alone: its list is a snapshot, and ReMe may know more than the plugin does.
*/
export function backendSpellingIsWrong(name: string): boolean {
// Own entries only. An inherited member is not a known backend: `constructor`
// and `__proto__` would otherwise be reported as known backends written in
// the wrong case, and the message would name a spelling ReMe refuses too.
return (
ownProtocol(name) === undefined &&
ownProtocol(name.toLowerCase()) !== undefined
);
}
/**
* The probe program for one provider, run by the selected interpreter inside
* the workspace so it reads the same `.env` the service would discover. The
* provider's protocol is baked into the program, so the probe cannot fall back
* to an OpenAI-style request for a provider that does not speak it.
*/
export function llmProbeSnippet(protocol: LlmProtocol): string {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...protocol.headers,
};
const auth = protocol.authHeader
? `headers[${JSON.stringify(protocol.authHeader)}] = ${JSON.stringify(
protocol.authPrefix,
)} + key\n`
: "";
// A provider whose own credentials ignore LLM_BASE_URL is probed with the
// provider's endpoint, exactly as the service would call it. Reading the
// variable anyway would send the probe to a host the service never uses.
const base =
protocol.baseSource === "provider"
? `base = ${JSON.stringify(protocol.providerBase ?? "")}\n`
: 'base = env.get("LLM_BASE_URL", "").strip().rstrip("/")\n';
const missingBase =
protocol.baseSource === "provider"
? ""
: 'if not base:\n missing.append("LLM_BASE_URL")\n';
return `import json, time, urllib.request, urllib.error, urllib.parse
env = {}
try:
with open(".env", encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
except OSError:
pass
${base}key = env.get("LLM_API_KEY", "").strip()
model = env.get("LLM_MODEL_NAME", "").strip()
missing = []
${missingBase}if not model:
missing.append("LLM_MODEL_NAME")
if ${protocol.keyRequired ? "True" : "False"} and not key:
missing.append("LLM_API_KEY")
if missing:
print(json.dumps({"ok": False, "status": 0, "latencyMs": 0, "error": "LLM config incomplete (" + ", ".join(missing) + ")"}))
raise SystemExit(0)
url = base + ${JSON.stringify(
protocol.path,
)}.replace("{model}", urllib.parse.quote(model, safe=""))
body = ${JSON.stringify(
protocol.body,
)}.replace("{model}", json.dumps(model)).encode()
headers = ${JSON.stringify(headers)}
${auth}request = urllib.request.Request(url, data=body, headers=headers, method="POST")
started = time.time()
try:
with urllib.request.urlopen(request, timeout=20) as response:
response.read()
print(json.dumps({"ok": 200 <= response.status < 300, "status": response.status, "latencyMs": int((time.time() - started) * 1000), "error": ""}))
except urllib.error.HTTPError as error:
print(json.dumps({"ok": False, "status": error.code, "latencyMs": int((time.time() - started) * 1000), "error": "HTTP " + str(error.code)}))
except Exception as error:
print(json.dumps({"ok": False, "status": 0, "latencyMs": int((time.time() - started) * 1000), "error": str(error)[:300]}))
`;
}
/**
* The LLM backend the workspace `.env` selects.
*
* `undefined` means the file does not hold the key at all: the service then
* falls back to its default. A present key is returned as its trimmed value,
* which may be the empty string — that names no backend at all, and ReMe
* refuses to start with it, so the caller has to know the difference.
*/
export function readLlmBackend(envPath: string): string | undefined {
let text = "";
try {
text = readFileSync(envPath, "utf8");
} catch {
return undefined;
}
const value = parseEnvFile(text)["LLM_BACKEND"];
return value === undefined ? undefined : value.trim();
}
/** Parse the JSON line the LLM test snippet prints. */
export function parseLlmTest(text: string): LlmTestResult {
for (const line of text.split(/\r?\n/).reverse()) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const value = JSON.parse(trimmed) as Record<string, unknown>;
return {
ok: value.ok === true,
status: typeof value.status === "number" ? value.status : 0,
latencyMs: typeof value.latencyMs === "number" ? value.latencyMs : 0,
error: typeof value.error === "string" ? value.error : "",
};
} catch {
// Try an earlier line.
}
}
return {
ok: false,
status: 0,
latencyMs: 0,
error: "LLM test produced no result",
};
}
/** Run the LLM probe with the selected interpreter inside the workspace. */
export async function testLlm(
io: { run: RunCommand },
options: { workspaceDir: string; pythonPath: string },
): Promise<LlmTestResult> {
// The backend in the workspace `.env` decides the protocol; the probe must
// not fall back to an OpenAI-style request for a provider that speaks its
// own, or it would report a working configuration as broken.
const backend = readLlmBackend(join(options.workspaceDir, ".env"));
// A present but empty backend names no backend. ReMe resolves its default
// only when the variable is absent, so the service would start with no
// backend at all and refuse to run: the honest answer is an incomplete
// configuration, and the request is not sent.
if (backend === "")
return {
ok: false,
status: 0,
latencyMs: 0,
error: "LLM config incomplete (LLM_BACKEND)",
};
// A known backend written in the wrong case is refused by ReMe's
// case-sensitive registry, so probing it would test a configuration the
// service cannot use.
if (backend !== undefined && backendSpellingIsWrong(backend))
return {
ok: false,
status: 0,
latencyMs: 0,
error: `LLM config invalid (LLM_BACKEND must be written in lower case: ${backend.toLowerCase()})`,
};
const protocol = llmProtocolFor(backend ?? DEFAULT_LLM_BACKEND);
try {
const result = await io.run(
options.pythonPath,
["-c", llmProbeSnippet(protocol)],
{
cwd: options.workspaceDir,
timeoutMs: 30_000,
},
);
if (result.error)
return {
ok: false,
status: 0,
latencyMs: 0,
error: safeError(result.error),
};
const parsed = parseLlmTest(result.stdout);
if (!parsed.ok && parsed.error) {
const hint =
parsed.status === 401 || parsed.status === 403
? "check LLM_API_KEY"
: parsed.status === 404
? "check LLM_MODEL_NAME / LLM_BASE_URL"
: /refused|timed out|getaddrinfo|Name or service/i.test(parsed.error)
? "check LLM_BASE_URL and network"
: "";
if (hint) parsed.error = `${parsed.error} (${hint})`;
}
return parsed;
} catch (error) {
return { ok: false, status: 0, latencyMs: 0, error: safeError(error) };
}
}

View file

@ -0,0 +1,30 @@
/**
* Canonical identity of an interpreter path, shared by the Host (install jobs)
* and the tab (matching a job to the interpreter on screen).
*
* Windows paths fold case and separators, because Windows does; POSIX paths
* stay case-sensitive. This module stays browser-safe: no Node built-ins,
* because the client imports it too.
*/
/** True when a path is written the Windows way: drive letter, UNC or backslash. */
export function isWindowsPath(value: string): boolean {
return (
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith("\\\\") ||
value.includes("\\")
);
}
/**
* The key two interpreter paths are compared by.
*
* The path flavor decides the comparison: a Windows path is written with
* forward slashes and lower case, a POSIX path keeps its own spelling,
* because it is case-sensitive there.
*/
export function interpreterKey(path: string): string {
const value = path.trim();
if (!isWindowsPath(value)) return value;
return value.replaceAll("\\", "/").toLowerCase();
}

View file

@ -0,0 +1,23 @@
/**
* The LLM backend the workspace `.env` selects.
*
* ReMe applies its default (`${LLM_BACKEND:-openai}`) only when the variable
* is **absent**: an explicitly empty value stays empty and names no backend,
* and the service then refuses to start. So the default has to be written out
* rather than left blank — see `environment/env-fields.ts`, which owns that
* rule.
*
* `llmBackendOf` is the *form's* reading, not the service's: an empty
* selection there means "use the default", so it resolves to the default
* backend. That is what the tab compares with, so picking the default in the
* form is not a change to the configuration.
*
* This module stays browser-safe: no Node built-ins, because the client
* imports it too.
*/
export const DEFAULT_LLM_BACKEND = "openai";
/** The backend a value means in the form, with the default the configuration applies. */
export function llmBackendOf(value: string): string {
return (value ?? "").trim().toLowerCase() || DEFAULT_LLM_BACKEND;
}

View file

@ -0,0 +1,239 @@
/**
* Environment contract shared by the DSH Host and the Environment tab.
*
* Everything here crosses the `remeEnv` RPC boundary, so it stays browser-safe:
* this module must not import a node built-in. Host-only shapes (process I/O,
* spawners, persisted state) live beside the code that owns them.
*/
/** A local Python interpreter discovered on the DSH Host. */
export type PythonOrigin =
| { kind: "conda"; envName: string; root: string }
| { kind: "python.org" }
| { kind: "windows-store" }
| { kind: "venv"; baseExe: string }
| { kind: "system" }
| { kind: "unknown" };
export interface PythonEnv {
/** Verified interpreter executable (absolute, host-native separators). */
exe: string;
/** "major.minor.patch" reported by the interpreter; empty when unusable. */
version: string;
/** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */
ok: boolean;
origin: PythonOrigin;
/** Human-readable discovery channels, e.g. "conda env list; PATH". */
source: string;
/** reme-ai installation facts, probed for the interpreter recommendation. */
reme?: { installed: boolean; version: string; versionOk?: boolean };
}
export interface PythonDetection {
platform: string;
scannedAt: string;
envs: PythonEnv[];
/** Preferred interpreter for a local service, or "" when none is usable. */
suggested: string;
/** Conda installation root, or "" when conda was not found. */
condaRoot: string;
/** Non-fatal probe failures worth showing for transparency. */
errors: string[];
}
/** Installed `reme-ai` facts for one interpreter. */
export interface RemeProbe {
installed: boolean;
version: string;
/** True when the version satisfies the minimum this integration targets. */
versionOk: boolean;
/** Non-empty when the probe itself failed (spawn error, timeout). */
error?: string;
}
export interface RemeCheck extends RemeProbe {
/** True when the AgentScope `[core]` extras import (agentscope) works. */
hasCore: boolean;
/** Absolute reme CLI path, or "" when only the python wrapper is available. */
cliPath: string;
/** Non-empty when the interpreter itself could not be probed. */
error: string;
}
export type InstallPhase = "running" | "succeeded" | "failed" | "cancelled";
export interface InstallRequest {
/** Absolute interpreter that receives the packages. */
pythonPath: string;
/** Upgrade an existing installation to the newest satisfying version. */
upgrade?: boolean;
/** Reinstall even when pip already considers the requirement satisfied. */
forceReinstall?: boolean;
/** Optional package index URL (mirror); empty keeps the user's pip config. */
indexUrl?: string;
}
export interface InstallStatus {
id: string;
phase: InstallPhase;
pythonPath: string;
/** Effective command line, for display and diagnostics. */
command: string;
startedAt: string;
finishedAt?: string;
exitCode?: number | null;
logPath: string;
/** Tail of the install log; safe to poll. */
logTail: string;
/** True between a cancel request and the pip process actually exiting. */
cancelling?: boolean;
}
export interface EnvEntry {
key: string;
secret: boolean;
/** True when the key has a non-empty value on disk. */
set: boolean;
/** Masked tail for secrets; the plain value for non-secret keys. */
masked: string;
/** Plain value for non-secret keys; empty for secrets. */
value: string;
}
export interface EnvSummary {
workspaceDir: string;
envPath: string;
exists: boolean;
entries: EnvEntry[];
}
export interface LlmTestResult {
ok: boolean;
status: number;
latencyMs: number;
error: string;
}
/** Stable reason a health probe failed, so the client can localize it. */
export type HealthErrorCode =
| "http-status"
| "not-reme"
| "reme-error"
| "unreachable";
/** Whether the configured endpoint answers, probed from the DSH Host. */
export interface ConnectionHealth {
endpoint: string;
ok: boolean;
version: string;
/** Non-empty when the endpoint answered but not as a ReMe service. */
error: string;
/** Stable reason; the client shows localized copy instead of `error`. */
code?: HealthErrorCode;
checkedAt: string;
}
export interface ServiceState {
phase: "running" | "stopped" | "conflict";
/** True when the running service was started by this plugin. */
managed: boolean;
endpoint: string;
pid?: number;
version?: string;
startedAt?: string;
/**
* The record that describes the process behind this status, when the Host
* could not prove that the process is the one the record describes.
*
* The process is then never signalled — an unreadable identity is no proof
* — but the record is what the tab has to drop to get the port back, so it
* travels with the status instead of waiting for a refusal that a disabled
* button can never trigger.
*/
record?: ServiceRecordRef;
logPath: string;
}
/** Stable reason for a failed start, so the client can localize it. */
export type ServiceStartErrorCode =
| "other-endpoint-starting"
| "conflict"
| "other-endpoint-running"
| "previous-not-stopped"
| "unverified-record"
| "no-python"
| "not-installed"
| "version-too-old"
| "core-missing"
| "start-failed";
/** Stable reason for a refused/unsuccessful stop, so the client can localize it. */
export type ServiceStopReasonCode =
| "no-record"
| "foreign"
| "unverified"
| "not-exited"
| "record-changed";
/**
* Identity of the service record an action was refused on behalf of.
*
* The record is not necessarily the endpoint on screen: a launch for another
* port can block the start of this one. The Host names the record it could not
* act on, so the recovery action can target that record instead of guessing.
*/
export interface ServiceRecordRef {
/** Endpoint the record was written for. */
endpoint: string;
/** Recorded pid, when the record has one. */
pid?: number;
}
export interface ServiceStartResult {
started: boolean;
alreadyRunning: boolean;
pid?: number;
version?: string;
command: string;
logPath: string;
startedAt: string;
/** English diagnostic; shown only when no known `code` applies. */
error?: string;
code?: ServiceStartErrorCode;
/** The record that blocked the start, when one did. */
record?: ServiceRecordRef;
}
export interface ServiceLog {
logPath: string;
lines: string;
}
/**
* Outcome of one Stop request. It crosses the `remeEnv` boundary, so it lives
* here rather than beside the Host-only process adapter.
*/
export interface ServiceStopResult {
stopped: boolean;
pid?: number;
/** Why an unmanaged or missing service was left alone. */
reason?: string;
/** Stable reason the client localizes; `reason` stays for diagnostics. */
code?: ServiceStopReasonCode;
/** The record that blocked the stop, when one did. */
record?: ServiceRecordRef;
}
/**
* Outcome of dropping a service record without touching the process. This is
* the escape hatch for a live process whose identity cannot be verified: Stop
* and Start both refuse to act on it, so the record can only be forgotten.
*/
export interface ServiceForgetResult {
forgotten: boolean;
pid?: number;
reason?: string;
code?: ServiceStopReasonCode;
/** The record that was refused, when one was. */
record?: ServiceRecordRef;
}

View file

@ -6,6 +6,8 @@ import type {} from "@deepseek-ai/dsh-session";
import { ReMeClient } from "./reme/client.js";
import { resolveConfig } from "./config.js";
import { ReMeEnvGateway } from "./environment/host/gateway.js";
import { ReMeEnvManager } from "./environment/host/manager.js";
import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js";
import { ReMeRuntime } from "./runtime.js";
import { ReMeStatusGateway } from "./status-gateway.js";
@ -25,7 +27,31 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
return () => runtime.disposeAll();
}, "remeMemory.lifecycle()");
ctx.provide("remeMemory", runtime);
const envManager = new ReMeEnvManager();
ctx.provide("remeEnv", envManager);
// The manager owns the bounded operations it started (pip installs), so it
// releases them when this plugin unloads. The detached ReMe service is not
// part of that lifetime: it is meant to outlive DSH.
ctx.effect(() => () => envManager.dispose(), "remeMemory.envLifetime()");
void ctx.plugin(ReMeStatusGateway);
void ctx.plugin(ReMeEnvGateway);
ctx.effect(() => {
const live = current();
if (live.autoStart)
void envManager
.autoStart({
endpoint: live.endpoint,
pythonPath: live.pythonPath,
workspaceDir: live.remeWorkspaceDir,
authToken: live.authToken,
})
.catch((error: unknown) => {
ctx.logger.warn("remeMemory.envAutoStart failed", {
error: String(error),
});
});
return () => {};
}, "remeMemory.envAutoStart()");
ctx.effect(
() => registerReMeTools(ctx, client, current),
"remeMemory.tools()",

View file

@ -52,9 +52,15 @@ export class ReMeClient {
sessionId: string,
options: AutoMemoryOptions = {},
): Promise<ReMeResult> {
const config = {
...this.configSource(),
...(options.endpoint ? { endpoint: options.endpoint } : {}),
const base = this.configSource();
const endpoint = options.endpoint || base.endpoint;
// A recovered batch keeps the endpoint it was queued for. The token is
// scoped to the *configured* endpoint, so it must not ride along when the
// batch goes somewhere else.
const config: ReMeClientConfig = {
...base,
endpoint,
...(endpoint === base.endpoint ? {} : { authToken: "" }),
};
return this.request(
"auto_memory",
@ -199,11 +205,16 @@ export class ReMeClient {
throw new TypeError(
"Invalid ReMe endpoint: credentials, query, and fragment are unsupported",
);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
// The token belongs to the endpoint it was configured for, so it is sent
// here and not on any other request this plugin makes.
if (config.authToken)
headers.Authorization = `Bearer ${config.authToken}`;
const response = await fetch(`${config.endpoint}/${job}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers,
body: JSON.stringify(payload),
signal,
});

View file

@ -3,6 +3,11 @@ export interface ReMeClientConfig {
endpoint: string;
requestTimeoutMs: number;
backgroundTimeoutMs: number;
/**
* Shared secret sent as `Authorization: Bearer` to this endpoint only. Empty
* or absent means the service runs without authentication.
*/
authToken?: string;
}
/** Normalized response returned by the ReMe HTTP client. */

View file

@ -2,6 +2,10 @@ import type { ReMeClientConfig } from "./reme/types.js";
export interface ReMeConfigInput {
endpoint?: string;
authToken?: string;
pythonPath?: string;
remeWorkspaceDir?: string;
autoStart?: boolean;
requestTimeoutMs?: number;
backgroundTimeoutMs?: number;
shutdownTimeoutMs?: number;
@ -22,6 +26,10 @@ export interface ReMeConfigInput {
export interface ReMeConfig extends ReMeClientConfig {
shutdownTimeoutMs: number;
authToken: string;
pythonPath: string;
remeWorkspaceDir: string;
autoStart: boolean;
autoMemoryEnabled: boolean;
autoMemoryInterval: number;
autoMemoryQueueDir: string;

View file

@ -31,6 +31,7 @@ test("declares one installable DeepSeek Harness plugin", async () => {
"0.2.1-alpha.2",
"0.2.1-alpha.4",
"0.2.1-rc.2",
"0.2.2-alpha.2",
]) {
assert.ok(
semver.satisfies(version, manifest.peerDependencies[dependency]),
@ -51,8 +52,34 @@ test("declares one installable DeepSeek Harness plugin", async () => {
assert.equal(manifest.peerDependenciesMeta[dependency]?.optional, true);
}
assert.equal(manifest.peerDependencies.openclaw, undefined);
// The framework ranges track the same releases as the DSH packages, so a
// supported DSH install cannot pull a framework the plugin rejects.
for (const [dependency, supported, rejected] of [
[
"@deepseek-ai/cordis",
["4.0.4", "4.0.5-alpha.1", "4.0.5", "4.0.6-alpha.1", "4.1.0"],
["4.0.3", "5.0.0"],
],
[
"@deepseek-ai/schemastery",
["3.18.4", "3.18.5-alpha.1", "3.18.5", "3.18.6-alpha.1"],
["3.18.3", "4.0.0"],
],
]) {
for (const version of supported)
assert.ok(
semver.satisfies(version, manifest.peerDependencies[dependency]),
`${dependency} ${version} should be supported`,
);
for (const version of rejected)
assert.equal(
semver.satisfies(version, manifest.peerDependencies[dependency]),
false,
`${dependency} ${version} should be rejected`,
);
}
assert.match(patch, /remeMemory: true/);
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/);
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/);
assert.doesNotMatch(patch, /id: reme-memory-runtime/);
assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/);
assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1);
@ -75,6 +102,14 @@ test("builds a lazy DSH browser module for the ReMe settings card", async () =>
assert.match(bundle, /Personal Knowledge Base/);
assert.match(statusPage, /个人知识库/);
assert.match(bundle, /health_check/);
// The settings card stages its drafts with DSH's own form model, shipped by
// the primitives package this bundle keeps external.
assert.match(
bundle,
/require\("@deepseek-ai\/dsh-client-ui-primitives"\)/,
"the client bundle resolves the primitives package at runtime",
);
assert.match(bundle, /SettingsFormModel/);
});
test("registers configuration on the ReMe bundle row", async () => {

View file

@ -1,12 +1,78 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import test from "node:test";
import vm from "node:vm";
import React from "react";
import { act, create } from "react-test-renderer";
import { resolveConfig } from "../dist/config.js";
/**
* The snapshot store the form model binds to.
*
* `@deepseek-ai/dsh-client-store` is not installed in this package, so the
* harness supplies the contract the real package offers: the store keeps the
* value it was given and hands that very reference back from `getSnapshot()`,
* and it notifies its listeners only when the value actually changed. The
* browser loads the real package; this stand-in only has to behave like it for
* the card's reads.
*/
function createSnapshotStore(init) {
let value = init;
const listeners = new Set();
return {
getSnapshot: () => value,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
set(next) {
if (Object.is(next, value)) return;
value = next;
for (const listener of [...listeners]) listener();
},
};
}
/**
* Evaluate the SettingsFormModel this plugin links against, straight out of the
* shipped primitives bundle.
*
* The package cannot be imported in Node (it loads CSS modules, icons and the
* syntax highlighter), but the form model region is plain JavaScript. Running
* that region means the card is exercised against DSH's real model instead of a
* re-implementation of it; a changed bundle layout fails here loudly.
*/
function loadSettingsFormModel() {
// Resolve the installed package rather than a fixed path: the packaged-plugin
// check installs it hoisted above this package, while the repository keeps it
// inside the package's own node_modules.
const manifest = createRequire(import.meta.url).resolve(
"@deepseek-ai/dsh-client-ui-primitives/package.json",
);
const source = readFileSync(
join(dirname(manifest), "lib", "index.js"),
"utf8",
);
const start = source.indexOf(
"//#region lib/types/settings-form/form-model.js",
);
const end = source.indexOf("//#endregion", start + 1);
assert.ok(
start >= 0 && end > start,
"unexpected @deepseek-ai/dsh-client-ui-primitives bundle layout",
);
// The model binds its projections through `createSnapshotStore`, which the
// browser gets from `@deepseek-ai/dsh-client-store`; the harness passes the
// stand-in above instead.
return new Function(
"createSnapshotStore",
`${source.slice(start, end)}\nreturn SettingsFormModel;`,
)(createSnapshotStore);
}
async function editor(mutate) {
let module;
const bundle = await readFile(
@ -15,6 +81,13 @@ async function editor(mutate) {
);
vm.runInNewContext(bundle, {
URL,
// The card disposes its form model on a deferred timer, so the new
// context needs the timer globals: without them the cleanup throws
// `ReferenceError: setTimeout is not defined` and React reports it.
setTimeout,
clearTimeout,
setInterval,
clearInterval,
document: {
head: { appendChild() {} },
createElement: () => ({ dataset: {}, remove() {} }),
@ -29,13 +102,18 @@ async function editor(mutate) {
});
const nodeRequire = createRequire(import.meta.url);
const client = module.factory((name) =>
name === "@deepseek-ai/dsh-client-ui-primitives" ? {} : nodeRequire(name),
name === "@deepseek-ai/dsh-client-ui-primitives"
? { SettingsFormModel: loadSettingsFormModel() }
: nodeRequire(name),
);
let Component;
let snapshot = {
status: "ready",
value: resolveConfig({}, {}),
base: resolveConfig({}, {}),
// The Host reports the user layer it stored; every field here is an
// override, which is what "reset to defaults" clears.
user: resolveConfig({}, {}),
revision: 7,
writable: true,
};
@ -98,14 +176,22 @@ async function editor(mutate) {
input.props.onChange({ target: { value } });
});
},
input(index) {
return renderer.root.findAllByType("input")[index].props;
},
button(label) {
return renderer.root
.findAllByType("button")
.find((button) => button.children.includes(label));
},
text: () => JSON.stringify(renderer.toJSON()),
close() {
async close() {
act(() => renderer.unmount());
// The card defers its disposal to the next tick, so the cleanup runs
// inside the test instead of after it.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
for (const dispose of disposers) dispose?.();
},
};
@ -131,7 +217,77 @@ test("saves all edited fields in one revision-fenced mutation", async () => {
assert.match(ui.text(), /saved/);
assert.equal(ui.button("save").props.disabled, true);
} finally {
ui.close();
await ui.close();
}
});
test("repaints the controls and the unsaved marker from the model's publishes", async () => {
const ui = await editor(async () => true);
try {
assert.doesNotMatch(ui.text(), /unsaved/);
// A staged edit publishes, and the card renders what the model holds: the
// control shows the draft and the card says the form is unsaved.
ui.change(0, "http://memory.test");
assert.equal(ui.input(0).value, "http://memory.test");
assert.match(ui.text(), /unsaved/);
// Dropping the drafts publishes as well, and the marker goes with them.
act(() => ui.button("discard").props.onClick());
assert.doesNotMatch(ui.text(), /unsaved/);
assert.notEqual(ui.input(0).value, "http://memory.test");
} finally {
await ui.close();
}
});
test("follows the namespace status through the model's publishes", async () => {
const ui = await editor(async () => true);
try {
assert.notEqual(ui.text(), "null");
// The card reads the scope through the model, so a status change reaches
// it as a publish rather than as a subscription of its own.
act(() => ui.update({ status: "loading" }));
assert.match(ui.text(), /loading/);
act(() => ui.update({ status: "unavailable" }));
assert.equal(ui.text(), "null");
} finally {
await ui.close();
}
});
test("the save button follows the model's own saving state", async () => {
let release = () => {};
const gate = new Promise((resolve) => {
release = resolve;
});
const ui = await editor(async () => {
await gate;
return true;
});
try {
ui.change(0, "http://memory.test");
await act(async () => {
ui.button("save").props.onClick();
});
// The write is in flight: the model's own state says so, and no second
// flag in the card could disagree with it.
const inFlight = ui.button("saving");
assert.ok(inFlight);
assert.equal(inFlight.props.disabled, true);
assert.equal(ui.button("save"), undefined);
assert.equal(ui.calls.length, 1);
await act(async () => {
release();
await new Promise((resolve) => setTimeout(resolve, 0));
});
// The save landed: the button is idle again, disabled because the form
// holds no drafts, and the result is reported.
assert.equal(ui.button("saving"), undefined);
assert.match(ui.text(), /"saved"/);
assert.equal(ui.button("save").props.disabled, true);
} finally {
await ui.close();
}
});
@ -154,7 +310,7 @@ for (const outcome of ["refused", "transport failure"]) {
act(() => ui.button("discard").props.onClick());
assert.doesNotMatch(ui.text(), /http:\/\/memory.test/);
} finally {
ui.close();
await ui.close();
}
});
}
@ -172,7 +328,7 @@ test("resets overrides in one mutation only after Save", async () => {
assert.ok(ui.calls[0].ops.every((op) => op.op === "unset"));
assert.equal(ui.calls[0].revision, 7);
} finally {
ui.close();
await ui.close();
}
});
@ -188,7 +344,7 @@ test("read-only configuration cannot be saved", async () => {
assert.equal(ui.button("save").props.disabled, true);
assert.match(ui.text(), /readOnly/);
} finally {
ui.close();
await ui.close();
}
});
@ -213,7 +369,7 @@ for (const endpoint of [
});
assert.equal(ui.calls.length, 0);
} finally {
ui.close();
await ui.close();
}
},
);

View file

@ -100,6 +100,32 @@ test("uses the persisted destination and request ID when sending a recovered bat
}
});
test("scopes the bearer token to the endpoint it was configured for", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init) => {
requests.push({ url, authorization: init.headers.Authorization });
return Response.json({ success: true });
};
try {
const client = new ReMeClient({
endpoint: "http://new.test",
authToken: "current-token",
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
});
// The current endpoint keeps the token.
await client.search("fact");
assert.equal(requests.at(-1).authorization, "Bearer current-token");
// A recovered batch for another endpoint must not receive it.
await client.autoMemory([], "session", { endpoint: "http://old.test" });
assert.equal(requests.at(-1).url, "http://old.test/auto_memory");
assert.equal(requests.at(-1).authorization, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("calls ReMe jobs with their native request and response envelopes", async () => {
const calls = [];
const originalFetch = globalThis.fetch;

View file

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const docs = join(dirname(fileURLToPath(import.meta.url)), "..", "docs");
/**
* Whether the DSH Web GUI renders this code span as a clickable link.
*
* The GUI's markdown renderer has an explicit case for it: an `inlineCode`
* node whose content is exactly an http(s) URL becomes
* `<code><a href="...">URL</a></code>` — the code chip is a link.
*
* function uU(e){ if(e.trim()===e) try{ const t=new URL(e).protocol;
* return t==="http:"||t==="https:"?e:void 0 }catch{return} }
*
* (from `@deepseek-ai/dsh-web-frontend`, the `inlineCode` case of the
* markdown renderer.) A standard CommonMark renderer leaves a code span inert,
* so this only shows up in the GUI — and an example endpoint is not something a
* reader should be able to click.
*/
function guiLinkifies(code) {
if (code.trim() !== code) return undefined;
try {
const protocol = new URL(code).protocol;
return protocol === "http:" || protocol === "https:" ? code : undefined;
} catch {
return undefined;
}
}
/** Code spans of a markdown document, fenced blocks left out. */
function codeSpans(text) {
const withoutFences = text.replace(/^```[\s\S]*?^```/gm, "");
return [...withoutFences.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
}
test("the user docs hold no code span the GUI turns into a link", (t) => {
for (const file of ["environment.md", "environment_ZH.md"]) {
const path = join(docs, file);
// The docs are part of the package (`files` includes `docs`), so the check
// runs in the packed install too. This branch only guards a checkout that
// does not have them.
if (!existsSync(path)) {
t.skip(`${file} is not part of this checkout`);
continue;
}
const offenders = codeSpans(readFileSync(path, "utf8")).filter(
(code) => guiLinkifies(code) !== undefined,
);
assert.deepEqual(
offenders,
[],
`${file} holds a code span the GUI renders as a clickable link`,
);
}
});

View file

@ -39,7 +39,7 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a
fiber: { state: 0 },
logger: { debug() {}, warn() {}, log() {} },
provide(name, value) {
assert.equal(name, "remeMemory");
assert.ok(name === "remeMemory" || name === "remeEnv");
assert.ok(value);
},
plugin() {
@ -261,3 +261,56 @@ function applyPlugin(t, ctx, input) {
});
return apply(ctx, { ...input, autoMemoryQueueDir: directory });
}
test("disposes the environment manager when the plugin unloads", async (t) => {
const effects = [];
const provided = {};
const directory = mkdtempSync(join(tmpdir(), "reme-dsh-dispose-"));
t.after(async () => {
// Stop the runtime before removing the queue directory it reads on shutdown.
await provided.remeMemory?.disposeAll();
rmSync(directory, { recursive: true, force: true });
});
const ctx = {
fiber: { state: 0 },
logger: { debug() {}, warn() {}, log() {} },
provide(name, value) {
provided[name] = value;
},
plugin() {
return Promise.resolve();
},
inject() {},
effect(execute, label) {
effects.push({ label, cleanup: execute() });
return () => {};
},
tools: {
register() {
return () => {};
},
},
on() {},
};
apply(ctx, {
autoMemoryEnabled: false,
autoDreamEnabled: false,
autoMemoryQueueDir: directory,
});
const manager = provided.remeEnv;
assert.ok(manager, "the environment manager is provided to the context");
let disposed = 0;
manager.dispose = () => {
disposed += 1;
};
// The plugin owns the lifetime of the installs it started, so unloading it
// must reach the manager rather than leaving pip running.
const lifetime = effects.find(
(entry) => entry.label === "remeMemory.envLifetime()",
);
assert.ok(lifetime?.cleanup, "the env lifetime effect registers a cleanup");
lifetime.cleanup();
assert.equal(disposed, 1);
});

View file

@ -0,0 +1,669 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
condaRootFromPath,
detectPythonEnvs,
isWindowsAppsAlias,
makeCondaRunner,
parseCondaEnvs,
parseCondaRoot,
parsePathList,
parsePyListPaths,
parseRegQuery,
parseVerifyOutput,
suggestInterpreter,
versionAtLeast,
} from "../dist/environment/host/detect.js";
function fakeRun(rules) {
return async (exe, args) => {
for (const rule of rules) {
if (rule.match(exe, args))
return typeof rule.result === "function"
? rule.result(args)
: rule.result;
}
throw new Error(`unexpected command: ${exe} ${args.join(" ")}`);
};
}
const VERIFY_ARG0 = "-c";
function verifyRule(exe, version, base) {
return {
match: (actual, args) =>
actual.toLowerCase() === exe.toLowerCase() &&
args[0] === VERIFY_ARG0 &&
String(args[1]).includes("import sys"),
result: {
code: 0,
stdout: `note\n{"exe":"${exe.replaceAll(
"\\",
"\\\\",
)}","ver":"${version}","base":"${base.replaceAll("\\", "\\\\")}"}\n`,
stderr: "",
},
};
}
/** reme-ai metadata probe; version null means "not installed". */
function remeRule(exe, version) {
return {
match: (actual, args) =>
actual.toLowerCase() === exe.toLowerCase() &&
args[0] === VERIFY_ARG0 &&
String(args[1]).includes("importlib.metadata"),
result: version
? { code: 0, stdout: `${version}\n`, stderr: "" }
: { code: 1, stdout: "", stderr: "PackageNotFoundError" },
};
}
function windowsFixture() {
const miniconda = "D:\\ProgramFils\\Miniconda";
const remeServe = `${miniconda}\\envs\\reme-serve`;
const old38 = `${miniconda}\\envs\\old38`;
const storePackage =
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0";
const rules = [
{
match: (exe, args) =>
exe === "reg.exe" &&
args[0] === "query" &&
args[1] === "HKCU\\Software\\Python",
result: {
code: 0,
stdout: [
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12",
" (默认) REG_SZ 3.12",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath`,
` (默认) REG_SZ ${miniconda}\\`,
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12\\InstallPath`,
" (Default) REG_SZ C:\\Python312\\",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonLegacy\\3.8\\InstallPath`,
" (Default) REG_SZ C:\\Python38",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11\\InstallPath`,
` (Default) REG_SZ ${storePackage}\\`,
"",
].join("\r\n"),
stderr: "",
},
},
{
match: (exe, args) => exe === "reg.exe" && args[0] === "query",
result: { code: 1, stdout: "", stderr: "key not found" },
},
{
match: (exe) => exe === "py.exe",
result: {
code: null,
stdout: "",
stderr: "",
error: "spawn py.exe ENOENT",
},
},
{
match: (exe, args) => exe === "where.exe" && args[0] === "python",
result: {
code: 0,
stdout: [
miniconda + "\\python.exe",
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe",
"",
].join("\r\n"),
stderr: "",
},
},
{
match: (exe, args) => exe === "where.exe" && args[0] === "conda",
result: {
code: 0,
stdout: `${miniconda}\\Library\\bin\\conda.bat\r\n`,
stderr: "",
},
},
{
match: (exe, args) =>
exe === `${miniconda}\\Scripts\\conda.exe` &&
args[0] === "info" &&
args[1] === "--json",
result: {
code: 0,
stdout: JSON.stringify({ root_prefix: miniconda }),
stderr: "",
},
},
{
match: (exe, args) =>
exe === `${miniconda}\\Scripts\\conda.exe` &&
args[0] === "env" &&
args[1] === "list",
result: {
code: 0,
stdout: JSON.stringify({
envs: [miniconda, remeServe, old38],
}),
stderr: "",
},
},
verifyRule(`${miniconda}\\python.exe`, "3.12.3", miniconda),
verifyRule(`${remeServe}\\python.exe`, "3.12.13", remeServe),
verifyRule(`${old38}\\python.exe`, "3.8.10", old38),
verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"),
verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"),
verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage),
remeRule(`${miniconda}\\python.exe`, "0.4.0.9"),
remeRule(`${remeServe}\\python.exe`, "0.4.0.9"),
remeRule(`${old38}\\python.exe`, null),
remeRule("C:\\Python312\\python.exe", "0.4.1.11"),
remeRule("C:\\Python38\\python.exe", null),
remeRule(`${storePackage}\\python.exe`, null),
];
return {
rules,
miniconda,
remeServe,
old38,
storePackage,
io: {
run: fakeRun(rules),
platform: "win32",
env: { USERPROFILE: "C:\\Users\\u", ProgramData: "C:\\ProgramData" },
homedir: "C:\\Users\\u",
exists: (path) => path === `${miniconda}\\Scripts\\conda.exe`,
},
};
}
test("parses PEP 514 registry output across locales, mojibake and empty values", () => {
const found = parseRegQuery(
[
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
" (\uFFFD\uFFFD) REG_SZ D:\\ProgramFils\\Miniconda\\",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.11\\InstallPath",
" (Default) REG_SZ C:\\Python311",
"",
"HKEY_CURRENT_USER\\Software\\Python\\Localized\\3.10\\InstallPath",
" (默认) REG_SZ C:\\Python310",
"",
"HKEY_CURRENT_USER\\Software\\Python\\Empty\\InstallPath",
" (\uFFFD\uFFFD) REG_SZ (\uFFFD\uFFFD\uFFFD\uFFFD)",
"",
].join("\r\n"),
);
assert.deepEqual(
found.map((entry) => entry.exe),
[
"D:\\ProgramFils\\Miniconda\\python.exe",
"C:\\Python311\\python.exe",
"C:\\Python310\\python.exe",
],
);
assert.equal(
found[0].source,
"registry HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
);
});
test("parses py launcher and path listings", () => {
assert.deepEqual(
parsePyListPaths(
[
" -V:3.12 * D:\\ProgramFils\\Miniconda\\python.exe",
" -V:3.11 C:\\Python311\\python.exe",
" -V:2.7 C:\\Python27\\python.exe",
].join("\n"),
),
[
"D:\\ProgramFils\\Miniconda\\python.exe",
"C:\\Python311\\python.exe",
"C:\\Python27\\python.exe",
],
);
assert.deepEqual(parsePathList("a\\python.exe\r\n\r\nb\\python.exe\n"), [
"a\\python.exe",
"b\\python.exe",
]);
});
test("drops Store alias stubs but keeps real Store interpreters", () => {
assert.equal(
isWindowsAppsAlias(
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python3.exe",
),
true,
);
assert.equal(
isWindowsAppsAlias(
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\\python.exe",
),
false,
);
assert.equal(isWindowsAppsAlias("C:\\Python312\\python.exe"), false);
});
test("compares interpreter versions numerically", () => {
assert.equal(versionAtLeast("3.12.3"), true);
assert.equal(versionAtLeast("3.11.0"), true);
assert.equal(versionAtLeast("3.12"), true);
assert.equal(versionAtLeast("3.10.8"), false);
assert.equal(versionAtLeast("3.8"), false);
assert.equal(versionAtLeast(""), false);
assert.equal(versionAtLeast("abc"), false);
});
test("parses interpreter verify output embedded in noisy stdout", () => {
const info = parseVerifyOutput(
'Warning: something\n{"exe":"D:\\\\x\\\\python.exe","ver":"3.12.3","base":"D:\\\\x"}\n',
);
assert.deepEqual(info, {
exe: "D:\\x\\python.exe",
version: "3.12.3",
basePrefix: "D:\\x",
});
assert.equal(parseVerifyOutput("no json here"), undefined);
});
test("parses conda json output defensively", () => {
assert.deepEqual(parseCondaEnvs('{"envs":["D:\\\\a","D:\\\\b"]}'), [
"D:\\a",
"D:\\b",
]);
assert.deepEqual(parseCondaEnvs("not json"), []);
assert.deepEqual(parseCondaEnvs('{"envs":42}'), []);
assert.equal(parseCondaRoot('{"root_prefix":"D:\\\\a"}'), "D:\\a");
assert.equal(parseCondaRoot("garbage"), "");
});
test("resolves every fixture with its own platform's path semantics", () => {
// The flavor comes from the path, not from the host running the test, so
// these assertions hold on Windows, macOS and Linux alike.
assert.equal(
condaRootFromPath("D:\\ProgramFils\\Miniconda\\Scripts\\conda.exe"),
"D:\\ProgramFils\\Miniconda",
);
assert.equal(
condaRootFromPath("D:\\ProgramFils\\Miniconda\\condabin\\conda.bat"),
"D:\\ProgramFils\\Miniconda",
);
assert.equal(
condaRootFromPath("/opt/homebrew/Caskroom/miniconda/base/bin/conda"),
"/opt/homebrew/Caskroom/miniconda/base",
);
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
});
test("derives conda roots and prefers the real conda.exe over the .bat", async () => {
assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc");
assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc");
assert.equal(
condaRootFromPath("/home/u/miniconda3/bin/conda"),
"/home/u/miniconda3",
);
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
const calls = [];
const run = async (exe, args) => {
calls.push([exe, args]);
return { code: 0, stdout: "{}", stderr: "" };
};
const runner = makeCondaRunner(
run,
"D:\\mc\\Library\\bin\\conda.bat",
true,
(path) => path === "D:\\mc\\Scripts\\conda.exe",
);
await runner(["info", "--json"]);
assert.deepEqual(calls[0][0], "D:\\mc\\Scripts\\conda.exe");
const fallback = [];
const runner2 = makeCondaRunner(
async (exe, args) => {
fallback.push([exe, args]);
return { code: null, stdout: "", stderr: "", error: "spawn ENOENT" };
},
"D:\\mc\\Library\\bin\\conda.bat",
true,
() => false,
);
await runner2(["env", "list", "--json"]);
assert.equal(fallback.at(-1)[0], "cmd.exe");
assert.ok(fallback.at(-1)[1].includes("/s"));
});
test("detects, classifies and suggests Windows interpreters end to end", async () => {
const { io, miniconda, remeServe, old38, storePackage } = windowsFixture();
const detection = await detectPythonEnvs(io);
assert.equal(detection.platform, "win32");
assert.equal(detection.condaRoot, miniconda);
assert.deepEqual(detection.errors, []);
assert.equal(detection.suggested, `${remeServe}\\python.exe`);
const exes = detection.envs.map((env) => env.exe.toLowerCase());
assert.ok(!exes.some((exe) => exe.includes("windowsapps\\\\python.exe")));
assert.ok(
!exes.some(
(exe) =>
exe ===
"c:\\users\\u\\appdata\\local\\microsoft\\windowsapps\\python.exe",
),
);
assert.equal(detection.envs.length, 6);
const base = detection.envs.find(
(env) => env.origin.kind === "conda" && env.origin.envName === "base",
);
assert.ok(base);
assert.equal(base.exe, `${miniconda}\\python.exe`);
assert.equal(base.version, "3.12.3");
assert.equal(base.ok, true);
assert.equal(base.origin.root, miniconda);
assert.ok(base.source.includes("PATH"));
assert.ok(base.source.includes("conda env list"));
assert.ok(base.source.includes("registry"));
const named = detection.envs.find(
(env) => env.exe === `${remeServe}\\python.exe`,
);
assert.ok(named);
assert.equal(named.origin.kind, "conda");
assert.equal(named.origin.envName, "reme-serve");
const legacy = detection.envs.find(
(env) => env.exe === `${old38}\\python.exe`,
);
assert.ok(legacy);
assert.equal(legacy.ok, false);
assert.ok(detection.envs.indexOf(legacy) > detection.envs.indexOf(base));
const pythonOrg = detection.envs.find(
(env) => env.exe === "C:\\Python312\\python.exe",
);
assert.ok(pythonOrg);
assert.equal(pythonOrg.origin.kind, "python.org");
const legacy38 = detection.envs.find(
(env) => env.exe === "C:\\Python38\\python.exe",
);
assert.ok(legacy38);
assert.equal(legacy38.ok, false);
const store = detection.envs.find(
(env) => env.exe === `${storePackage}\\python.exe`,
);
assert.ok(store);
assert.equal(store.origin.kind, "windows-store");
assert.equal(store.ok, true);
assert.deepEqual(base.reme, {
installed: true,
version: "0.4.0.9",
versionOk: true,
});
assert.deepEqual(named.reme, {
installed: true,
version: "0.4.0.9",
versionOk: true,
});
assert.deepEqual(pythonOrg.reme, {
installed: true,
version: "0.4.1.11",
versionOk: true,
});
assert.deepEqual(store.reme, {
installed: false,
version: "",
versionOk: false,
});
});
test("suggests nothing usable by preferring reme installs, then named conda envs", () => {
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "base", root: "r" },
source: "",
},
{
exe: "b",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "reme", root: "r" },
source: "",
},
]),
"b",
);
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.8.0",
ok: false,
origin: { kind: "conda", envName: "base", root: "r" },
source: "",
},
{
exe: "c",
version: "3.11.9",
ok: true,
origin: { kind: "python.org" },
source: "",
},
]),
"c",
);
// A reme-enabled interpreter outranks a bare named conda env.
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "tools", root: "r" },
source: "",
reme: { installed: false, version: "" },
},
{
exe: "b",
version: "3.12.0",
ok: true,
origin: { kind: "python.org" },
source: "",
reme: { installed: true, version: "0.4.0.9" },
},
]),
"b",
);
// An outdated reme does not make an interpreter ready.
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "tools", root: "r" },
source: "",
reme: { installed: true, version: "0.3.1.8" },
},
{
exe: "b",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "other", root: "r" },
source: "",
reme: { installed: false, version: "" },
},
]),
"a",
);
assert.equal(suggestInterpreter([]), "");
});
test("detects Unix interpreters and merges paths that resolve together", async () => {
const rules = [
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "python3",
result: { code: 0, stdout: "/usr/bin/python3\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "python",
result: { code: 0, stdout: "/usr/bin/python\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "conda",
result: { code: 0, stdout: "/home/u/miniconda3/bin/conda\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "/home/u/miniconda3/bin/conda" && args[0] === "info",
result: {
code: 0,
stdout: JSON.stringify({ root_prefix: "/home/u/miniconda3" }),
stderr: "",
},
},
{
match: (exe, args) =>
exe === "/home/u/miniconda3/bin/conda" && args[0] === "env",
result: {
code: 0,
stdout: JSON.stringify({
envs: ["/home/u/miniconda3", "/home/u/miniconda3/envs/reme"],
}),
stderr: "",
},
},
verifyRule("/usr/bin/python3", "3.10.12", "/usr"),
// /usr/bin/python is a shim whose sys.executable reports python3.
{
match: (actual, args) =>
actual === "/usr/bin/python" &&
args[0] === VERIFY_ARG0 &&
String(args[1]).includes("import sys"),
result: {
code: 0,
stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n',
stderr: "",
},
},
verifyRule(
"/home/u/miniconda3/bin/python3",
"3.12.1",
"/home/u/miniconda3",
),
verifyRule(
"/home/u/miniconda3/envs/reme/bin/python3",
"3.11.9",
"/home/u/miniconda3/envs/reme",
),
remeRule("/usr/bin/python3", null),
remeRule("/usr/bin/python", null),
remeRule("/home/u/miniconda3/bin/python3", "0.4.1.11"),
remeRule("/home/u/miniconda3/envs/reme/bin/python3", "0.4.1.11"),
];
const detection = await detectPythonEnvs({
run: fakeRun(rules),
platform: "linux",
env: {},
homedir: "/home/u",
exists: () => false,
});
assert.equal(detection.platform, "linux");
assert.equal(detection.condaRoot, "/home/u/miniconda3");
assert.equal(detection.suggested, "/home/u/miniconda3/envs/reme/bin/python3");
const system = detection.envs.filter((env) => env.origin.kind === "system");
assert.equal(system.length, 1);
assert.equal(system[0].exe, "/usr/bin/python3");
assert.equal(system[0].ok, false);
const named = detection.envs.find((env) => env.origin.envName === "reme");
assert.ok(named);
assert.equal(named.ok, true);
assert.equal(named.version, "3.11.9");
});
test("probes well-known Unix locations beyond the Host PATH", async () => {
const homebrew = "/opt/homebrew/bin/python3";
const pyenv = "/Users/u/.pyenv/shims/python3";
const rules = [
{
match: (exe) => exe === "which",
result: { code: 1, stdout: "", stderr: "" },
},
verifyRule(homebrew, "3.12.4", "/opt/homebrew"),
// pyenv shims report the real interpreter they dispatch to.
{
match: (actual, args) =>
actual === pyenv &&
args[0] === VERIFY_ARG0 &&
String(args[1]).includes("import sys"),
result: {
code: 0,
stdout:
'{"exe":"/Users/u/.pyenv/versions/3.12.4/bin/python3","ver":"3.12.4","base":"/Users/u/.pyenv/versions/3.12.4"}\n',
stderr: "",
},
},
remeRule(homebrew, "0.4.1.11"),
remeRule(pyenv, null),
remeRule("/Users/u/.pyenv/versions/3.12.4/bin/python3", null),
];
const detection = await detectPythonEnvs({
run: fakeRun(rules),
platform: "darwin",
env: {},
homedir: "/Users/u",
exists: (path) => path === homebrew || path === pyenv,
});
assert.deepEqual(
detection.envs.map((env) => env.exe).sort(),
[homebrew, "/Users/u/.pyenv/versions/3.12.4/bin/python3"].sort(),
);
assert.ok(
detection.envs.every((env) => env.origin.kind === "system"),
"well-known paths are classified as system installations",
);
});
test("collects conda failures without rejecting the whole detection", async () => {
const { io, miniconda } = windowsFixture();
io.run = async (exe, args) => {
if (exe === `${miniconda}\\Scripts\\conda.exe`)
return { code: null, stdout: "", stderr: "", error: "spawn failed" };
const original = windowsFixture();
const rule = original.rules.find((entry) => entry.match(exe, args));
if (!rule) throw new Error(`unexpected: ${exe} ${args.join(" ")}`);
return typeof rule.result === "function" ? rule.result(args) : rule.result;
};
const detection = await detectPythonEnvs(io);
assert.equal(detection.condaRoot, "");
assert.ok(detection.errors.some((error) => error.startsWith("conda:")));
assert.ok(
detection.envs.some((env) => env.exe === `${miniconda}\\python.exe`),
);
});

View file

@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ENV_FIELDS,
envFieldMeta,
envUpdates,
MANAGED_KEYS,
SECRET_KEYS,
storedState,
} from "../dist/environment/env-fields.js";
import { DEFAULT_LLM_BACKEND } from "../dist/environment/llm-backend.js";
/** A file that already holds a value for every key that needs one. */
const STORED = { EMBEDDING_API_KEY: true };
test("the managed keys are the shared list, in one order", () => {
assert.deepEqual(MANAGED_KEYS, [
"LLM_BACKEND",
"LLM_MODEL_NAME",
"LLM_API_KEY",
"LLM_BASE_URL",
"EMBEDDING_BACKEND",
"EMBEDDING_MODEL_NAME",
"EMBEDDING_API_KEY",
"EMBEDDING_BASE_URL",
]);
assert.deepEqual(
[...MANAGED_KEYS],
ENV_FIELDS.map((field) => field.key),
);
});
test("only the two credentials are secret", () => {
assert.deepEqual([...SECRET_KEYS].sort(), [
"EMBEDDING_API_KEY",
"LLM_API_KEY",
]);
for (const key of MANAGED_KEYS)
assert.equal(SECRET_KEYS.has(key), key.endsWith("API_KEY"), key);
});
test("the metadata of a key carries its secret flag and placeholder", () => {
// What an empty draft means follows the `secret` flag, which the update
// rules below apply. A key that names a default is the documented
// exception.
assert.equal(envFieldMeta("LLM_API_KEY")?.secret, true);
assert.equal(envFieldMeta("LLM_BASE_URL")?.secret, false);
assert.equal(envFieldMeta("EMBEDDING_API_KEY")?.disabledValue, "EMPTY");
assert.equal(envFieldMeta("LLM_BASE_URL")?.disabledValue, undefined);
assert.equal(envFieldMeta("NOT_MANAGED"), undefined);
});
test("an untouched key does not travel", () => {
const updates = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, STORED);
assert.deepEqual(updates, { LLM_MODEL_NAME: "qwen3.7-plus" });
// The stored value of every other key survives the save.
assert.equal("LLM_API_KEY" in updates, false);
assert.equal("LLM_BASE_URL" in updates, false);
assert.equal("EMBEDDING_API_KEY" in updates, false);
});
test("a blank secret is dropped, so the stored credential survives", () => {
// The key was typed and cleared again: the draft holds an empty value, but
// an empty secret means "keep what is stored".
const updates = envUpdates(
{ LLM_API_KEY: "", EMBEDDING_API_KEY: "" },
{ LLM_API_KEY: true, EMBEDDING_API_KEY: true },
);
assert.equal("LLM_API_KEY" in updates, false);
assert.equal("EMBEDDING_API_KEY" in updates, false);
assert.deepEqual(updates, {});
});
test("a blank non-secret is an explicit clear", () => {
const updates = envUpdates({ LLM_BASE_URL: "" }, STORED);
assert.deepEqual(updates, { LLM_BASE_URL: "" });
});
test("a typed secret travels", () => {
const updates = envUpdates({ LLM_API_KEY: "sk-new" }, STORED);
assert.deepEqual(updates, { LLM_API_KEY: "sk-new" });
});
test("the embedding key is written as EMPTY while it is unset", () => {
// ReMe 0.4.0.x instantiates the embedding component unconditionally, so an
// empty key there aborts the service start.
const untouched = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, {});
assert.equal(untouched.EMBEDDING_API_KEY, "EMPTY");
// A cleared embedding key is the same case: it must not stay empty.
const cleared = envUpdates(
{ EMBEDDING_API_KEY: "" },
{ EMBEDDING_API_KEY: false },
);
assert.equal(cleared.EMBEDDING_API_KEY, "EMPTY");
// A stored key is never replaced by the placeholder.
const stored = envUpdates({}, { EMBEDDING_API_KEY: true });
assert.equal("EMBEDDING_API_KEY" in stored, false);
});
test("the stored state reports which keys hold a value", () => {
// The tab reads this from the masked projection, so a set secret counts as
// present through its non-empty mask.
const state = storedState({
LLM_MODEL_NAME: "qwen3.7-plus",
LLM_API_KEY: "",
});
assert.equal(state.LLM_MODEL_NAME, true);
assert.equal(state.LLM_API_KEY, false);
assert.equal(state.LLM_BASE_URL, false);
assert.deepEqual(
Object.keys(state).sort(),
[...MANAGED_KEYS].sort(),
"every managed key is reported",
);
});
test("a key the tab does not manage travels unchanged", () => {
// The Host must not silently drop a value it was asked to write.
const updates = envUpdates(
{ LLM_MODEL_NAME: "qwen3.7-plus", SECRET_OTHER: "value" },
STORED,
);
assert.deepEqual(updates, {
LLM_MODEL_NAME: "qwen3.7-plus",
SECRET_OTHER: "value",
});
});
test("an empty backend selection is written as the default, not as an empty value", () => {
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is absent:
// an explicitly empty value names no backend, and the service refuses to
// start with it. So the cleared selection writes the default out.
const updates = envUpdates({ LLM_BACKEND: "" }, STORED);
assert.deepEqual(updates, { LLM_BACKEND: DEFAULT_LLM_BACKEND });
assert.equal(updates.LLM_BACKEND, "openai");
assert.notEqual(updates.LLM_BACKEND, "");
});
test("a typed backend still travels", () => {
const updates = envUpdates({ LLM_BACKEND: "dashscope" }, STORED);
assert.deepEqual(updates, { LLM_BACKEND: "dashscope" });
});
test("an untouched backend key does not travel", () => {
// The stored backend survives the save, exactly like every other key.
const updates = envUpdates({ LLM_MODEL_NAME: "qwen3.7-plus" }, STORED);
assert.equal("LLM_BACKEND" in updates, false);
});
test("the default rule does not touch the other keys", () => {
// A blank secret still keeps the stored credential, and a key that must not
// stay empty still gets its placeholder.
const secret = envUpdates(
{ LLM_API_KEY: "", LLM_BACKEND: "" },
{ LLM_API_KEY: true, EMBEDDING_API_KEY: true },
);
assert.equal("LLM_API_KEY" in secret, false);
assert.equal(secret.LLM_BACKEND, "openai");
const placeholder = envUpdates(
{ EMBEDDING_API_KEY: "" },
{ EMBEDDING_API_KEY: false },
);
assert.equal(placeholder.EMBEDDING_API_KEY, "EMPTY");
// A non-secret key without a default is still an explicit clear.
const cleared = envUpdates({ LLM_BASE_URL: "" }, STORED);
assert.deepEqual(cleared, { LLM_BASE_URL: "" });
});
test("both backends name a default", () => {
// ReMe 0.4.0.x declares `as_embedding` with
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so the
// embedding backend follows the same rule as the LLM one.
assert.equal(envFieldMeta("LLM_BACKEND")?.defaultValue, "openai");
assert.equal(envFieldMeta("EMBEDDING_BACKEND")?.defaultValue, "openai");
assert.equal(envFieldMeta("LLM_MODEL_NAME")?.defaultValue, undefined);
assert.equal(envFieldMeta("LLM_BASE_URL")?.defaultValue, undefined);
});
test("a cleared embedding backend is written as the default", () => {
// The same failure as the LLM backend: an empty value names no backend, and
// ReMe refuses to start with one.
const updates = envUpdates({ EMBEDDING_BACKEND: "" }, STORED);
assert.deepEqual(updates, { EMBEDDING_BACKEND: "openai" });
});

View file

@ -0,0 +1,453 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create } from "react-test-renderer";
import {
ENV_GROUPS,
WorkspaceSection,
} from "../dist/environment/client/workspace.js";
import { envZh, fill } from "../dist/environment/client/locales.js";
import { MANAGED_KEYS } from "../dist/environment/env-fields.js";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const WORKSPACE = "C:\\Users\\test\\.reme";
const ENV_PATH = `${WORKSPACE}\\.env`;
/** The summary the Host answers a read with: every managed key, in order. */
const SUMMARY = {
workspaceDir: WORKSPACE,
exists: true,
envPath: ENV_PATH,
entries: [
{
key: "LLM_BACKEND",
value: "dashscope",
masked: "dashscope",
secret: false,
set: true,
},
{
key: "LLM_MODEL_NAME",
value: "qwen3.7-plus",
masked: "qwen3.7-plus",
secret: false,
set: true,
},
{
key: "LLM_API_KEY",
value: "",
masked: "••••cret",
secret: true,
set: true,
},
{
key: "LLM_BASE_URL",
value: "https://example.com/v1",
masked: "https://example.com/v1",
secret: false,
set: true,
},
{
key: "EMBEDDING_BACKEND",
value: "openai",
masked: "openai",
secret: false,
set: true,
},
{
key: "EMBEDDING_MODEL_NAME",
value: "text-embedding-v4",
masked: "text-embedding-v4",
secret: false,
set: true,
},
{
key: "EMBEDDING_API_KEY",
value: "",
masked: "••••MPTY",
secret: true,
set: true,
},
{
key: "EMBEDDING_BASE_URL",
value: "",
masked: "",
secret: false,
set: false,
},
],
};
const t = (key) => envZh[key];
function textOf(value) {
if (Array.isArray(value)) return value.map(textOf).join("");
return typeof value === "string" ? value : "";
}
/** The `.env` form, driven by the real section and its own workspace hook. */
function Harness({ rpc, workspaceDir = WORKSPACE }) {
return React.createElement(WorkspaceSection, {
// The tab mounts the section with the workspace as its key, so a switch
// disposes the previous workspace's state.
key: workspaceDir,
t,
rpc,
workspaceDir,
pythonPath: "C:\\env\\python.exe",
writable: true,
onWorkspaceDir: async () => true,
});
}
async function mount(options = {}) {
const calls = { rpc: [] };
let resolveWrite;
const pending = options.pendingWrite
? new Promise((resolve) => {
resolveWrite = resolve;
})
: undefined;
const rpc = {
call: async (_path, method, payload) => {
calls.rpc.push({ method, input: payload?.args?.input });
if (method === "remeEnv/readEnv") return { ok: true, value: SUMMARY };
if (method === "remeEnv/writeEnv") {
if (pending) return { ok: true, value: await pending };
return { ok: true, value: SUMMARY };
}
return { ok: true, value: null };
},
};
let renderer;
await act(async () => {
renderer = create(React.createElement(Harness, { rpc }));
});
// The card starts collapsed; the form is behind the expand button.
await act(async () => {
buttonByText(renderer, envZh.expand).props.onClick();
});
// Wait for the summary to arrive instead of guessing how long it takes.
await waitFor(() => fieldValue(renderer, "model") === "qwen3.7-plus");
return {
renderer,
calls,
resolveWrite(value = SUMMARY) {
resolveWrite(value);
},
restore: () => {
act(() => {
renderer.unmount();
});
},
};
}
/** Let the effects settle until a condition holds, or the wait runs out. */
async function waitFor(check, tries = 100) {
for (let attempt = 0; attempt < tries; attempt += 1) {
if (check()) return;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
assert.ok(check(), "the form did not reach the expected state");
}
function buttonByText(renderer, label) {
const found = renderer.root.findAll(
(node) => node.type === "button" && textOf(node.props.children) === label,
);
assert.ok(found.length > 0, `button not found: ${label}`);
return found[0];
}
/**
* The `.env` controls, in the order the form renders them: the two backend
* selects are not inputs, so the six inputs are the two groups' text and
* secret fields.
*/
const FIELD = {
model: 0,
apiKey: 1,
baseUrl: 2,
embeddingModel: 3,
embeddingApiKey: 4,
embeddingBaseUrl: 5,
};
/** The inputs of the `.env` form, without the workspace directory field. */
function envInputs(renderer) {
const container = renderer.root.findAll((node) =>
String(node.props.className ?? "").includes("reme-env-config"),
);
assert.equal(container.length, 1, "the .env form is rendered once");
return container[0].findAll((node) => node.type === "input");
}
function fieldInput(renderer, name) {
return envInputs(renderer)[FIELD[name]];
}
/** The value a `.env` field shows right now. */
function fieldValue(renderer, name) {
return fieldInput(renderer, name)?.props.value ?? "";
}
/** The save's own report, or an empty string while there is none. */
function statusText(renderer) {
const found = renderer.root.findAll((node) =>
String(node.props.className ?? "").includes("reme-settings-success"),
);
if (!found.length) return "";
return textOf(found[0].props.children);
}
/** Every success message on screen, in render order. */
function successTexts(renderer) {
return renderer.root
.findAll((node) =>
String(node.props.className ?? "").includes("reme-settings-success"),
)
.map((node) => textOf(node.props.children));
}
function writeCall(calls) {
const write = calls.rpc
.filter((call) => call.method === "remeEnv/writeEnv")
.at(-1);
assert.ok(write, "the save reached the Host");
return write.input;
}
test("the form shows every managed key, in the shared order", async (t) => {
const groups = ENV_GROUPS;
assert.deepEqual(
groups.flatMap((group) => group.fields.map((field) => field.key)),
[...MANAGED_KEYS],
);
// The secret flag is the shared one, not a second copy of it.
assert.deepEqual(
groups
.flatMap((group) => group.fields)
.filter((field) => field.kind === "secret")
.map((field) => field.key),
["LLM_API_KEY", "EMBEDDING_API_KEY"],
);
const { renderer, restore } = await mount();
t.after(restore);
const inputs = envInputs(renderer);
assert.equal(inputs.length, 6);
assert.equal(inputs[FIELD.apiKey].props.type, "password");
assert.equal(inputs[FIELD.embeddingApiKey].props.type, "password");
assert.equal(fieldInput(renderer, "model").props.value, "qwen3.7-plus");
});
test("an edit typed while the save is in flight survives the save", async (t) => {
const { renderer, calls, resolveWrite, restore } = await mount({
pendingWrite: true,
});
t.after(restore);
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-b" },
});
});
await act(async () => {
buttonByText(renderer, envZh.saveEnv).props.onClick();
});
// The save is in flight and the field stays editable.
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-c" },
});
});
await act(async () => {
resolveWrite();
});
await waitFor(() => statusText(renderer) !== "");
// The save carried the value that was submitted...
assert.equal(writeCall(calls).values.LLM_MODEL_NAME, "model-b");
// ...and the newer edit is still on screen instead of being reverted.
assert.equal(fieldValue(renderer, "model"), "model-c");
});
test("a blank secret keeps the stored credential, a blank field clears it", async (t) => {
const { renderer, calls, restore } = await mount();
t.after(restore);
// A replacement key is typed and cleared again: "leave blank to keep".
await act(async () => {
fieldInput(renderer, "apiKey").props.onChange({
target: { value: "sk-new" },
});
});
await act(async () => {
fieldInput(renderer, "apiKey").props.onChange({ target: { value: "" } });
});
// A non-secret field is cleared explicitly.
await act(async () => {
fieldInput(renderer, "baseUrl").props.onChange({ target: { value: "" } });
});
await act(async () => {
buttonByText(renderer, envZh.saveEnv).props.onClick();
});
await waitFor(() => statusText(renderer) !== "");
const values = writeCall(calls).values;
// The emptied secret does not travel: the stored credential survives.
assert.equal("LLM_API_KEY" in values, false);
// The emptied field is an explicit clear.
assert.equal(values.LLM_BASE_URL, "");
// Nothing else travelled either.
assert.equal("LLM_MODEL_NAME" in values, false);
assert.equal("EMBEDDING_API_KEY" in values, false);
});
test("the saved message admits that newer edits are still pending", async (t) => {
const { renderer, resolveWrite, restore } = await mount({
pendingWrite: true,
});
t.after(restore);
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-b" },
});
});
await act(async () => {
buttonByText(renderer, envZh.saveEnv).props.onClick();
});
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-c" },
});
});
await act(async () => {
resolveWrite();
});
await waitFor(() => statusText(renderer) !== "");
// The form is not clean, so the message must not read like a clean save.
assert.equal(
statusText(renderer),
fill(envZh.savedEnvPending, { path: ENV_PATH }),
);
assert.notEqual(
statusText(renderer),
fill(envZh.savedEnv, { path: ENV_PATH }),
);
});
test("a save without newer edits reports a clean form", async (t) => {
const { renderer, restore } = await mount();
t.after(restore);
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-b" },
});
});
await act(async () => {
buttonByText(renderer, envZh.saveEnv).props.onClick();
});
await waitFor(() => statusText(renderer) !== "");
assert.equal(statusText(renderer), fill(envZh.savedEnv, { path: ENV_PATH }));
});
const OTHER_WORKSPACE = "D:\\Other\\.reme";
const OTHER_ENV_PATH = `${OTHER_WORKSPACE}\\.env`;
/** The summary of another workspace, so a switch is visible in the form. */
const OTHER_SUMMARY = {
...SUMMARY,
workspaceDir: OTHER_WORKSPACE,
envPath: OTHER_ENV_PATH,
entries: SUMMARY.entries.map((entry) =>
entry.key === "LLM_MODEL_NAME"
? { ...entry, value: "other-model", masked: "other-model" }
: entry,
),
};
/** A Host whose `.env` reads answer for the workspace they were asked about. */
function switchingRpc() {
return {
call: async (_path, method, payload) => {
const input = payload?.args?.input ?? {};
if (method === "remeEnv/readEnv")
return {
ok: true,
value:
input.workspaceDir === OTHER_WORKSPACE ? OTHER_SUMMARY : SUMMARY,
};
if (method === "remeEnv/writeEnv") return { ok: true, value: SUMMARY };
if (method === "remeEnv/testLlm")
return {
ok: true,
value: { ok: true, status: 200, latencyMs: 12, error: "" },
};
return { ok: true, value: null };
},
};
}
test("a workspace switch drops the completed save and LLM-test results", async (t) => {
const rpc = switchingRpc();
let renderer;
await act(async () => {
renderer = create(React.createElement(Harness, { rpc }));
});
t.after(() => act(() => renderer.unmount()));
await act(async () => {
buttonByText(renderer, envZh.expand).props.onClick();
});
await waitFor(() => fieldValue(renderer, "model") === "qwen3.7-plus");
// A completed save and a successful probe, both for the first workspace.
await act(async () => {
fieldInput(renderer, "model").props.onChange({
target: { value: "model-b" },
});
});
await act(async () => {
buttonByText(renderer, envZh.saveEnv).props.onClick();
});
await waitFor(() =>
successTexts(renderer).includes(fill(envZh.savedEnv, { path: ENV_PATH })),
);
await act(async () => {
buttonByText(renderer, envZh.testLlmButton).props.onClick();
});
await waitFor(() =>
successTexts(renderer).includes(fill(envZh.llmOk, { ms: 12 })),
);
// The tab remounts the section with the new workspace as its key.
await act(async () => {
renderer.update(
React.createElement(Harness, { rpc, workspaceDir: OTHER_WORKSPACE }),
);
});
await act(async () => {
buttonByText(renderer, envZh.expand).props.onClick();
});
await waitFor(() => fieldValue(renderer, "model") === "other-model");
// Neither result belonged to this workspace, so neither is shown.
assert.deepEqual(successTexts(renderer), []);
// The form shows this workspace's file, not the previous workspace's.
const shown = (text) =>
renderer.root.findAll((node) => textOf(node.props.children).includes(text))
.length;
assert.ok(shown(OTHER_ENV_PATH) > 0, "the new .env path is on screen");
assert.equal(shown(ENV_PATH), 0, "the previous .env path is gone");
});

View file

@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Context } from "@deepseek-ai/cordis";
import { ReMeEnvGateway } from "../dist/environment/host/gateway.js";
/** Manager stub: no subprocesses, just enough surface for the gateway. */
function stubManager() {
const calls = [];
return {
calls,
detectPython: async () => ({
platform: "win32",
scannedAt: "",
envs: [],
suggested: "",
condaRoot: "",
errors: [],
}),
checkReme: async (pythonPath) => ({
installed: false,
version: "",
versionOk: false,
hasCore: false,
cliPath: "",
error: pythonPath ? "" : "Python interpreter path must be absolute",
}),
health: async (endpoint, authToken) => ({
endpoint,
ok: false,
version: "",
error: authToken ? "auth token forwarded" : "",
checkedAt: "",
}),
installStatus: async (jobId, pythonPath) => {
calls.push({ method: "installStatus", jobId, pythonPath });
return null;
},
serviceForget: async (endpoint, expectPid) => {
calls.push({ method: "serviceForget", endpoint, expectPid });
return {
forgotten: false,
reason: endpoint,
code: "no-record",
};
},
};
}
/** Mount the gateway inside the same isolated realm the patch creates. */
async function mounted() {
const root = new Context();
let realm;
let manager;
await root.plugin((ctx) => {
realm = ctx.isolate({ remeMemory: true });
});
await realm.plugin((ctx) => {
ctx.provide("remeMemory", {});
manager = stubManager();
ctx.provide("remeEnv", manager);
});
await realm.plugin(ReMeEnvGateway);
return { root, realm, manager };
}
test("gateway mounts without a service-key conflict and keeps the wire namespace", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
assert.ok(receiver, "gateway service is registered under its own key");
assert.ok(receiver.typertRemote, "gateway exposes a typertRemote binding");
assert.equal(receiver.typertRemote.serviceKey, "remeEnvGateway");
assert.equal(receiver.typertRemote.namespace, "remeEnv");
// The manager must stay reachable under the key the methods inject.
assert.ok(root.reflect.get("remeEnv", false));
});
test("SRC discovery exposes remeEnv/python with one plain input parameter", async () => {
const { root } = await mounted();
let binding;
for (const [serviceKey, definition] of Object.entries(root.reflect.props)) {
if (definition.type !== "service") continue;
const receiver = root.reflect.get(serviceKey, false);
if (!receiver?.typertRemote) continue;
if (receiver.typertRemote.namespace !== "remeEnv") continue;
binding = { serviceKey, receiver };
}
assert.ok(binding, "an active service exports the remeEnv namespace");
assert.equal(binding.serviceKey, "remeEnvGateway");
const source = Function.prototype.toString.call(
Object.getPrototypeOf(binding.receiver).python,
);
const open = source.indexOf("(");
const close = source.indexOf(")", open + 1);
const parameters = source
.slice(open + 1, close)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
// The Gateway parses this source, so defaults, destructuring, and rest
// parameters would make the endpoint call fail at runtime.
assert.deepEqual(parameters, ["input"]);
const result = await binding.receiver.python({ refresh: true });
assert.equal(result.platform, "win32");
});
test("gateway methods remain callable through the manager after remount", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
const check = await receiver.checkReme({ pythonPath: "" });
assert.equal(check.installed, false);
assert.match(check.error, /absolute/);
});
test("health keeps the single-input convention and forwards the token", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
const source = Function.prototype.toString.call(
Object.getPrototypeOf(receiver).health,
);
const open = source.indexOf("(");
const close = source.indexOf(")", open + 1);
const parameters = source
.slice(open + 1, close)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
assert.deepEqual(parameters, ["input"]);
const result = await receiver.health({
endpoint: "http://127.0.0.1:2334",
authToken: "secret",
});
assert.equal(result.endpoint, "http://127.0.0.1:2334");
assert.equal(result.error, "auth token forwarded");
});
test("serviceForget keeps the single-input convention and forwards the endpoint", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
const source = Function.prototype.toString.call(
Object.getPrototypeOf(receiver).serviceForget,
);
const open = source.indexOf("(");
const close = source.indexOf(")", open + 1);
const parameters = source
.slice(open + 1, close)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
assert.deepEqual(parameters, ["input"]);
const result = await receiver.serviceForget({
endpoint: "http://127.0.0.1:2334",
});
assert.equal(result.forgotten, false);
assert.equal(result.reason, "http://127.0.0.1:2334");
});
test("the gateway forwards the interpreter lookup and the pinned record", async () => {
const { root, manager } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
// The tab restores the running job of one interpreter, not the newest job.
await receiver.installStatus({ pythonPath: "C:\\env\\a\\python.exe" });
const status = manager.calls.find((call) => call.method === "installStatus");
assert.equal(status.jobId, "");
assert.equal(status.pythonPath, "C:\\env\\a\\python.exe");
// The confirmed recovery targets the record the Host named, pinned by pid.
await receiver.serviceForget({
endpoint: "http://127.0.0.1:2333",
expectPid: 6000,
});
const forget = manager.calls.find((call) => call.method === "serviceForget");
assert.equal(forget.endpoint, "http://127.0.0.1:2333");
assert.equal(forget.expectPid, 6000);
});

View file

@ -0,0 +1,377 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InstallJobs } from "../dist/environment/host/install.js";
/**
* A fake `InstallSpawner`: no pip is really spawned and every termination the
* job requests is recorded.
*
* The fake child models a stubborn process: it survives the graceful signal
* and dies only when the force-kill arrives. It never reports an exit on its
* own, because whether the Host ever observes one is exactly what these
* tests are about.
*/
function fakeSpawner() {
const children = [];
const calls = { starts: 0 };
const spawner = {
start(options) {
calls.starts += 1;
// The real spawner opens the job log before spawning, so pruning must
// see the new file too.
if (options?.logPath) writeFileSync(options.logPath, "");
const child = {
pid: 4000 + children.length,
/** Whether the process still exists. */
alive: true,
/** Whether the managed group still holds a process after the exit. */
groupAliveFlag: false,
/** Every termination the job asked for, in order. */
kills: [],
onExit(callback) {
child.exit = callback;
},
kill(force = false) {
child.kills.push(force ? "force" : "graceful");
if (force) child.alive = false;
},
/** What the real spawner asks the OS: is the group still populated? */
groupAlive() {
return child.groupAliveFlag;
},
/** The Host observed the process exiting. */
exit(code) {
child.alive = false;
child.exit?.(code);
},
};
children.push(child);
return child;
},
};
return { spawner, children, calls };
}
/**
* A controllable stand-in for `setTimeout`.
*
* The install job uses one timer only - the escalation from the graceful
* signal to the force-kill - so driving it by hand keeps the tests
* deterministic and free of real waiting.
*/
function fakeTimers(t) {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const pending = new Map();
let sequence = 0;
globalThis.setTimeout = (callback, delay) => {
sequence += 1;
const timer = {
id: sequence,
delay,
/** Whether the job unref()ed the timer. */
unrefed: false,
unref() {
timer.unrefed = true;
return timer;
},
ref() {
return timer;
},
};
pending.set(timer.id, { timer, callback });
return timer;
};
globalThis.clearTimeout = (timer) => {
if (timer && typeof timer.id === "number") pending.delete(timer.id);
};
t.after(() => {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
});
return {
/** The timers that are still pending. */
pending: () => [...pending.values()].map((entry) => entry.timer),
/** Fire every pending timer, oldest first. */
fire() {
const entries = [...pending.values()];
pending.clear();
for (const { callback } of entries) callback();
return entries.length;
},
/**
* Fire every pending timer, including the ones they schedule themselves.
*
* This is what "after every escalation timer has fired" means: a chain of
* timers must be drained, not only the first one.
*/
fireAll() {
let fired = 0;
for (let round = 0; round < 10; round += 1) {
const entries = [...pending.values()];
if (entries.length === 0) break;
pending.clear();
for (const { callback } of entries) callback();
fired += entries.length;
}
return fired;
},
};
}
function makeJobs(t, options = {}) {
const directory = mkdtempSync(join(tmpdir(), "reme-install-jobs-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const { spawner, children, calls } = fakeSpawner();
const jobs = new InstallJobs({ spawner, logDir: directory, ...options });
return { jobs, children, calls, directory };
}
test("a cancelled job stays running and cancelling until the exit is observed", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t, { killGraceMs: 5_000 });
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(jobs.cancel(started.id), true);
// The graceful signal goes out first.
assert.deepEqual(children[0].kills, ["graceful"]);
// Every escalation timer has fired and the child still never exited.
timers.fireAll();
const status = jobs.status(started.id);
assert.equal(status?.phase, "running");
assert.equal(status?.cancelling, true);
// A failed termination must not be reported as a completed cancellation.
assert.notEqual(status?.phase, "cancelled");
assert.notEqual(status?.phase, "failed");
assert.equal(status?.exitCode, undefined);
assert.equal(status?.finishedAt, undefined);
// The force-kill was sent, so the job is waiting for a real exit.
assert.deepEqual(children[0].kills, ["graceful", "force"]);
});
test("an unconfirmed cancellation keeps the interpreter locked", (t) => {
const timers = fakeTimers(t);
const { jobs, children, calls } = makeJobs(t, { killGraceMs: 5_000 });
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
timers.fireAll(); // The escalation fired; the child never exited.
// A second installation for the same interpreter must not spawn a second
// child while the first one is still unconfirmed.
const again = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(again.id, started.id);
assert.equal(calls.starts, 1);
assert.equal(children.length, 1);
// The lock is visible through the interpreter lookup as well.
const locked = jobs.status("", "C:\\env\\python.exe");
assert.equal(locked?.id, started.id);
assert.equal(locked?.cancelling, true);
});
test("the interpreter lock folds case on Windows paths", (t) => {
const { jobs, calls } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
// The Host finds the same interpreter in another spelling.
const again = jobs.start({ pythonPath: "c:/env/python.exe" });
assert.equal(again.id, started.id);
assert.equal(calls.starts, 1);
assert.equal(jobs.status("", "C:\\ENV\\PYTHON.EXE")?.id, started.id);
// A POSIX path stays case-sensitive.
const posix = jobs.start({ pythonPath: "/opt/venv/bin/python" });
assert.notEqual(posix.id, started.id);
assert.equal(jobs.status("", "/opt/Venv/bin/python"), null);
});
test("cancel escalates to the force-kill after the grace period", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t, { killGraceMs: 1_234 });
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
// The force-kill is not sent before the grace period is over.
assert.deepEqual(children[0].kills, ["graceful"]);
const pending = timers.pending();
assert.equal(pending.length, 1);
assert.equal(pending[0].delay, 1_234);
// A pending escalation must never keep the Host process alive.
assert.equal(pending[0].unrefed, true);
timers.fire();
assert.deepEqual(children[0].kills, ["graceful", "force"]);
});
test("the default cancellation grace gives pip five seconds to clean up", (t) => {
const timers = fakeTimers(t);
const { jobs } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
const pending = timers.pending();
assert.equal(pending.length, 1);
assert.equal(pending[0].delay, 5_000);
});
test("dispose force-kills a child that ignores the graceful signal", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t); // The default dispose grace is 0.
jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.dispose();
// The force-kill follows the graceful signal immediately, so a child that
// ignores it cannot outlive the Host.
assert.deepEqual(children[0].kills, ["graceful", "force"]);
assert.equal(children[0].alive, false);
// Nothing is left pending: the plugin is unloading.
assert.equal(timers.pending().length, 0);
});
test("dispose escalates after the configured grace period", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t, { disposeGraceMs: 250 });
jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.dispose();
assert.deepEqual(children[0].kills, ["graceful"]);
assert.equal(children[0].alive, true);
const pending = timers.pending();
assert.equal(pending.length, 1);
assert.equal(pending[0].delay, 250);
assert.equal(pending[0].unrefed, true);
timers.fire();
assert.deepEqual(children[0].kills, ["graceful", "force"]);
assert.equal(children[0].alive, false);
});
test("a cancelled job waits for its process group, not only for its leader", (t) => {
const timers = fakeTimers(t);
const { jobs, children, calls } = makeJobs(t, {
killGraceMs: 5_000,
groupPollMs: 25,
groupTimeoutMs: 60_000,
});
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
// The leader exits on the graceful signal while a build child that ignored
// it keeps the managed group alive: the regression the review reproduced.
children[0].groupAliveFlag = true;
children[0].exit(1);
let status = jobs.status(started.id);
assert.equal(status?.phase, "running");
assert.equal(status?.cancelling, true);
// The group gets the force-kill, and the job waits for it to be empty.
assert.deepEqual(children[0].kills, ["graceful", "force"]);
assert.equal(status?.finishedAt, undefined);
// While the group is alive the interpreter stays locked.
assert.equal(
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
started.id,
);
assert.equal(calls.starts, 1);
// The force-kill cleared the group: the next confirmation settles the job
// and releases the interpreter.
children[0].groupAliveFlag = false;
timers.fireAll();
status = jobs.status(started.id);
assert.equal(status?.phase, "cancelled");
assert.equal(status?.exitCode, 1);
assert.equal(status?.cancelling, undefined);
assert.notEqual(
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
started.id,
);
assert.equal(calls.starts, 2);
// Nothing is left pending once the group is confirmed empty.
assert.equal(timers.pending().length, 0);
});
test("a group that never empties keeps the job unconfirmed", (t) => {
const timers = fakeTimers(t);
const { jobs, children, calls } = makeJobs(t, {
killGraceMs: 5_000,
groupPollMs: 25,
groupTimeoutMs: 60_000,
});
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
children[0].groupAliveFlag = true;
children[0].exit(1);
// Every poll keeps finding the group alive, so the job never settles.
timers.fireAll();
const status = jobs.status(started.id);
assert.equal(status?.phase, "running");
assert.equal(status?.cancelling, true);
assert.notEqual(status?.phase, "cancelled");
// The interpreter is still locked: a build child may still be running.
assert.equal(
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
started.id,
);
assert.equal(calls.starts, 1);
});
test("a cancelled job with an observed exit settles once, as cancelled", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t, { killGraceMs: 5_000 });
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
children[0].exit(1);
const settled = jobs.status(started.id);
assert.equal(settled?.phase, "cancelled");
assert.equal(settled?.cancelling, undefined);
assert.equal(settled?.exitCode, 1);
// The pending escalation is dropped, so it cannot fire after the exit.
assert.equal(timers.pending().length, 0);
// A second, late exit must not settle the job a second time.
children[0].exit(1);
const again = jobs.status(started.id);
assert.equal(again?.phase, "cancelled");
assert.equal(again?.finishedAt, settled?.finishedAt);
});
test("dispose does not settle the job; the observed exit does", (t) => {
const timers = fakeTimers(t);
const { jobs, children } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.dispose();
const status = jobs.status(started.id);
assert.equal(status?.phase, "running");
assert.equal(status?.cancelling, true);
assert.equal(status?.finishedAt, undefined);
// The default dispose grace is zero, so the force-kill already went out and
// nothing is left pending.
assert.equal(timers.pending().length, 0);
// The interpreter is still locked: the child may still be dying.
assert.equal(
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
started.id,
);
children[0].exit(null);
assert.equal(jobs.status(started.id)?.phase, "cancelled");
});
test("the status of an unconfirmed cancellation stays JSON-safe", (t) => {
const timers = fakeTimers(t);
const { jobs } = makeJobs(t, { killGraceMs: 5_000 });
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.cancel(started.id);
timers.fireAll();
const status = jobs.status(started.id);
assert.equal(status?.phase, "running");
// Optional keys are omitted, never written as undefined.
assert.equal("exitCode" in status, false);
assert.equal("finishedAt" in status, false);
assert.deepEqual(JSON.parse(JSON.stringify(status)), status);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import {
checkReme,
compareVersions,
parseRemeVersion,
remeCliPath,
remeVersionAtLeast,
} from "../dist/environment/host/reme.js";
import {
InstallJobs,
pipArgs,
tailLog,
} from "../dist/environment/host/install.js";
const SNIPPETS = {
version: "importlib.metadata",
core: "import agentscope",
};
function remeIO(handlers, platform = "win32") {
return {
run: async (exe, args) => {
const handler = handlers[exe];
if (!handler) return { code: 1, stdout: "", stderr: "no handler" };
return handler(args);
},
platform,
exists: (path) =>
path.endsWith("Scripts\\reme.exe") || path.endsWith("bin\\reme"),
};
}
test("compares dotted versions across ReMe's four-segment scheme", () => {
assert.equal(compareVersions("0.4.0.9", "0.4.0"), 1);
assert.equal(compareVersions("0.4.0", "0.4.0"), 0);
assert.equal(compareVersions("0.3.9.9", "0.4.0"), -1);
assert.equal(compareVersions("1.0", "0.4.0.9"), 1);
assert.equal(remeVersionAtLeast("0.4.0.9"), true);
assert.equal(remeVersionAtLeast("0.4.0"), true);
assert.equal(remeVersionAtLeast("0.4.1.11"), true);
assert.equal(remeVersionAtLeast("0.3.2"), false);
assert.equal(remeVersionAtLeast(""), false);
assert.equal(remeVersionAtLeast("v0.4.0"), false);
});
test("parses a version line out of noisy metadata output", () => {
assert.equal(parseRemeVersion("0.4.0.9\n"), "0.4.0.9");
assert.equal(parseRemeVersion("warning\n0.4.0.9\ntrailer"), "0.4.0.9");
assert.equal(parseRemeVersion("PackageNotFoundError"), "");
assert.equal(parseRemeVersion(""), "");
});
test("checks an installed interpreter including [core] and the CLI", async () => {
const io = remeIO({
"C:\\env\\python.exe": (args) =>
String(args[1]).includes(SNIPPETS.version)
? { code: 0, stdout: "0.4.0.9\n", stderr: "" }
: { code: 0, stdout: "", stderr: "" },
});
const check = await checkReme(io, "C:\\env\\python.exe");
assert.equal(check.installed, true);
assert.equal(check.version, "0.4.0.9");
assert.equal(check.versionOk, true);
assert.equal(check.hasCore, true);
assert.equal(check.cliPath, "C:\\env\\Scripts\\reme.exe");
assert.equal(check.error, "");
});
test("finds the CLI for venv interpreters that live inside Scripts", async () => {
const io = remeIO({
"C:\\venv\\Scripts\\python.exe": (args) =>
String(args[1]).includes(SNIPPETS.version)
? { code: 0, stdout: "0.4.1.1\n", stderr: "" }
: { code: 0, stdout: "", stderr: "" },
});
io.exists = (path) => path.toLowerCase() === "c:\\venv\\scripts\\reme.exe";
const check = await checkReme(io, "C:\\venv\\Scripts\\python.exe");
assert.equal(check.cliPath, "C:\\venv\\Scripts\\reme.exe");
});
test("flags old versions and missing [core] extras", async () => {
const io = remeIO({
"C:\\env\\python.exe": (args) =>
String(args[1]).includes(SNIPPETS.version)
? { code: 0, stdout: "0.3.2\n", stderr: "" }
: { code: 1, stdout: "", stderr: "ModuleNotFoundError" },
});
const check = await checkReme(io, "C:\\env\\python.exe");
assert.equal(check.installed, true);
assert.equal(check.versionOk, false);
assert.equal(check.hasCore, false);
});
test("reports a missing installation without core or CLI", async () => {
const io = remeIO({
"C:\\env\\python.exe": () => ({ code: 1, stdout: "", stderr: "not found" }),
});
const check = await checkReme(io, "C:\\env\\python.exe");
assert.equal(check.installed, false);
assert.equal(check.versionOk, false);
assert.equal(check.hasCore, false);
assert.equal(check.cliPath, "");
});
test("rejects relative interpreter paths", async () => {
const check = await checkReme(remeIO({}), "python.exe");
assert.equal(check.installed, false);
assert.match(check.error, /absolute/);
});
test("composes pip arguments for install, upgrade and mirrors", () => {
assert.deepEqual(pipArgs({ pythonPath: "C:\\env\\python.exe" }), [
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"reme-ai[core]",
]);
assert.deepEqual(
pipArgs({ pythonPath: "C:\\env\\python.exe", upgrade: true }),
[
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"-U",
"reme-ai[core]",
],
);
assert.deepEqual(
pipArgs({
pythonPath: "C:\\env\\python.exe",
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
}).slice(-3),
[
"--index-url",
"https://pypi.tuna.tsinghua.edu.cn/simple",
"reme-ai[core]",
],
);
assert.ok(
pipArgs({
pythonPath: "C:\\env\\python.exe",
forceReinstall: true,
}).includes("--force-reinstall"),
);
assert.throws(() =>
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
);
// Credentials would be echoed in the job command, the log and the browser.
assert.throws(
() =>
pipArgs({
pythonPath: "C:\\env\\python.exe",
indexUrl: "https://user:token@mirror.example/simple",
}),
/must not embed credentials/,
);
});
test("tails a log with CRLF and progress carriage returns", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-install-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const path = join(directory, "install.log");
writeFileSync(path, "line1\r\nline2\r\nprogress 40%\rprogress 80%\rline3\n");
const tail = tailLog(path);
assert.ok(tail.includes("line1"));
assert.ok(tail.includes("line3"));
assert.ok(tail.includes("progress 80%"));
assert.equal(tailLog(join(directory, "missing.log")), "");
});
function fakeSpawner() {
const children = [];
const spawner = {
start(options) {
// The real spawner opens the job log before spawning, so pruning must see
// the new file too.
if (options?.logPath) writeFileSync(options.logPath, "");
const child = {
pid: 4000 + children.length,
killed: false,
onExit(callback) {
child.exit = callback;
},
kill(force = false) {
child.killed = true;
child.force = force;
},
};
children.push(child);
return child;
},
};
return { spawner, children };
}
function makeJobs(t) {
const directory = mkdtempSync(join(tmpdir(), "reme-jobs-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const { spawner, children } = fakeSpawner();
const jobs = new InstallJobs({ spawner, logDir: directory });
return { jobs, children, directory };
}
test("finds the reme CLI beside a Windows interpreter on any host", () => {
const seen = [];
const cli = remeCliPath(
{
run: async () => ({ code: 0, stdout: "", stderr: "" }),
platform: "win32",
exists: (path) => {
seen.push(path);
return path.endsWith("reme.exe");
},
},
"C:\\env\\python.exe",
);
assert.equal(cli, "C:\\env\\Scripts\\reme.exe");
assert.deepEqual(seen, ["C:\\env\\Scripts\\reme.exe"]);
});
test("finds the reme CLI in a POSIX bin directory", () => {
const cli = remeCliPath(
{
run: async () => ({ code: 0, stdout: "", stderr: "" }),
platform: "linux",
exists: () => true,
},
"/home/u/venv/bin/python",
);
assert.equal(cli, "/home/u/venv/bin/reme");
});
test("keeps exactly the documented number of install logs", (t) => {
const { jobs, directory } = makeJobs(t);
for (let index = 0; index < 24; index += 1)
writeFileSync(join(directory, `install-old-${index}.log`), "x");
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
const remaining = readdirSync(directory).filter((name) =>
name.endsWith(".log"),
);
// The steady state is the documented count, not one more than it.
assert.equal(remaining.length, 20);
assert.ok(remaining.includes(basename(started.logPath)));
});
test("tracks a successful pip job through exit", (t) => {
const { jobs, children, directory } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(started.phase, "running");
assert.ok(started.logPath.startsWith(directory));
assert.match(started.command, /reme-ai\[core\]/);
assert.equal(jobs.status()?.id, started.id);
children[0].exit(0);
const done = jobs.status(started.id);
assert.equal(done?.phase, "succeeded");
assert.equal(done?.exitCode, 0);
assert.ok(done?.finishedAt);
});
test("tracks a failed pip job and keeps the log tail", (t) => {
const { jobs, children } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
writeFileSync(started.logPath, "ERROR: Could not find a version\n");
children[0].exit(1);
const done = jobs.status(started.id);
assert.equal(done?.phase, "failed");
assert.equal(done?.exitCode, 1);
assert.match(done?.logTail ?? "", /Could not find a version/);
});
test("keeps the interpreter locked until a cancelled job exits", (t) => {
const { jobs, children } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(jobs.cancel(started.id), true);
assert.equal(children[0].killed, true);
// The process still owns the interpreter, so the job stays running until the
// exit is observed and no second installation may start.
const cancelling = jobs.status(started.id);
assert.equal(cancelling?.phase, "running");
assert.equal(cancelling?.cancelling, true);
assert.equal(
jobs.start({ pythonPath: "C:\\env\\python.exe" }).id,
started.id,
);
assert.equal(jobs.cancel(started.id), false);
children[0].exit(1); // A late exit settles the cancelled job, not a failure.
assert.equal(jobs.status(started.id)?.phase, "cancelled");
assert.equal(jobs.status(started.id)?.cancelling, undefined);
});
test("dispose stops the installations the manager still owns", (t) => {
const { jobs, children } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\python.exe" });
jobs.dispose();
assert.equal(children[0].killed, true);
children[0].exit(null);
assert.equal(jobs.status(started.id)?.phase, "cancelled");
});
test("reuses the running job for the same interpreter and validates paths", (t) => {
const { jobs } = makeJobs(t);
const first = jobs.start({ pythonPath: "C:\\env\\python.exe" });
const second = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(first.id, second.id);
assert.throws(() => jobs.start({ pythonPath: "relative\\python.exe" }));
// Root-relative Windows paths pass win32.isAbsolute but are not absolute in
// the sense the install needs (they resolve against the current drive).
assert.throws(() => jobs.start({ pythonPath: "\\python.exe" }));
assert.equal(jobs.status("unknown-job"), null);
});
test("restores the running job of one interpreter, not the newest job", (t) => {
// Two interpreters may install at the same time, so "the newest job" is not
// necessarily the one a tab is looking for.
const { jobs } = makeJobs(t);
const first = jobs.start({ pythonPath: "C:\\env\\a\\python.exe" });
const second = jobs.start({ pythonPath: "C:\\env\\b\\python.exe" });
assert.notEqual(first.id, second.id);
assert.equal(jobs.status("", "C:\\env\\a\\python.exe")?.id, first.id);
assert.equal(jobs.status("", "C:\\env\\b\\python.exe")?.id, second.id);
// An interpreter without a running job has nothing to restore.
assert.equal(jobs.status("", "C:\\env\\c\\python.exe"), null);
// Without an interpreter the newest job overall is still reported.
assert.equal(jobs.status("")?.id, second.id);
assert.equal(jobs.status(first.id)?.id, first.id);
// A named job wins over the interpreter lookup: the poller asks for its own
// job by id.
assert.equal(jobs.status(first.id, "C:\\env\\b\\python.exe")?.id, first.id);
});
test("an interpreter is matched by its own path semantics", (t) => {
const { jobs } = makeJobs(t);
const started = jobs.start({ pythonPath: "C:\\env\\a\\python.exe" });
// Windows paths fold case and separators, so the same interpreter is found.
assert.equal(jobs.status("", "c:/env/a/python.exe")?.id, started.id);
// A POSIX path stays case-sensitive.
const posix = jobs.start({ pythonPath: "/opt/venv/bin/python" });
assert.equal(jobs.status("", "/opt/venv/bin/python")?.id, posix.id);
assert.equal(jobs.status("", "/opt/Venv/bin/python"), null);
});

View file

@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { canHostService } from "../dist/environment/dependency.js";
import {
isLocalEndpoint,
parseLocalEndpoint,
} from "../dist/environment/endpoint-rules.js";
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
/** A manager whose process work is replaced by facts. */
function managerIn(t) {
const directory = mkdtempSync(join(tmpdir(), "reme-rules-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const manager = new ReMeEnvManager(
{ DSH_HOME: directory },
{
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
portOpen: async () => false,
spawnService: async () => ({ pid: 1 }),
killTree() {},
probePid() {},
processImage: async () => "python.exe",
processCommandLine: async () => "",
exists: () => true,
platform: "win32",
},
);
return { manager, directory };
}
const INSTALLED = {
installed: true,
version: "0.4.2.1",
versionOk: true,
hasCore: true,
cliPath: "",
error: "",
};
test("the interpreter readiness has one definition", () => {
// Supported CLI releases need AgentScope even before running any jobs.
assert.equal(canHostService(INSTALLED), true);
assert.equal(canHostService(undefined), false);
assert.equal(
canHostService({ ...INSTALLED, installed: false, versionOk: false }),
false,
);
assert.equal(canHostService({ ...INSTALLED, versionOk: false }), false);
assert.equal(canHostService({ ...INSTALLED, hasCore: false }), false);
});
test("only a loopback HTTP endpoint is a manageable local service", () => {
assert.equal(isLocalEndpoint("http://127.0.0.1:2333"), true);
assert.equal(isLocalEndpoint("http://localhost:2333"), true);
// A trailing slash is still the root of the origin.
assert.equal(isLocalEndpoint("http://127.0.0.1:2333/"), true);
assert.equal(isLocalEndpoint("https://127.0.0.1:2333"), false);
assert.equal(isLocalEndpoint("http://10.0.0.5:2333"), false);
assert.equal(isLocalEndpoint("http://[::1]:2333"), false);
assert.equal(isLocalEndpoint("not-a-url"), false);
// A path prefix is a gateway, not a service this plugin can launch: the
// start command configures host and port only, so the startup health check
// at `/reme/health_check` would 404 there. Such an endpoint is still probed
// as a remote one, at its own path.
assert.equal(isLocalEndpoint("http://127.0.0.1:2333/reme"), false);
assert.equal(isLocalEndpoint("http://localhost:2333/reme"), false);
// The Host and the tab read the same rule, so the port they report is the
// same one.
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), {
host: "127.0.0.1",
port: 2334,
});
assert.deepEqual(parseLocalEndpoint("http://localhost:2333"), {
host: "127.0.0.1",
port: 2333,
});
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334/"), {
host: "127.0.0.1",
port: 2334,
});
assert.throws(
() => parseLocalEndpoint("http://127.0.0.1:2333/reme"),
/root-path/,
);
});
test("an emptied secret never reaches the .env file", (t) => {
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(
envPath,
[
"LLM_MODEL_NAME=qwen3.7-plus",
"LLM_API_KEY=sk-keep",
"EMBEDDING_API_KEY=EMPTY",
"",
].join("\n"),
);
// "Leave blank to keep": the stored credential survives, so the other
// changes are saved instead of being blocked by the empty value.
const summary = manager.writeEnv(directory, {
LLM_MODEL_NAME: "qwen3.8-plus",
LLM_API_KEY: "",
});
const text = readFileSync(envPath, "utf8");
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
assert.match(text, /^LLM_MODEL_NAME=qwen3.8-plus$/m);
assert.equal(
summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME").value,
"qwen3.8-plus",
);
});
test("an emptied non-secret field is still an explicit clear", (t) => {
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(
envPath,
"LLM_API_KEY=sk-keep\nLLM_BASE_URL=https://example.com/v1\n",
);
manager.writeEnv(directory, { LLM_BASE_URL: "" });
const text = readFileSync(envPath, "utf8");
assert.match(text, /^LLM_BASE_URL=$/m);
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
});
test("the embedding key keeps the placeholder that lets ReMe start", (t) => {
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(envPath, "LLM_API_KEY=sk-keep\n");
// ReMe 0.4.0.x needs a non-empty embedding key even without vector search,
// so an unset one is written as the documented placeholder.
manager.writeEnv(directory, {});
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=EMPTY$/m);
// A stored key survives; the placeholder is not written over it.
writeFileSync(envPath, "LLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=real-key\n");
manager.writeEnv(directory, {});
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=real-key$/m);
});
test("a required key that was never stored still fails the save", (t) => {
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(envPath, "EMBEDDING_API_KEY=EMPTY\n");
// "Keep" can only keep what is stored; without a stored LLM key the file
// cannot start ReMe, so the save is refused and the file is left alone.
assert.throws(
() => manager.writeEnv(directory, { LLM_API_KEY: "" }),
/LLM_API_KEY is required/,
);
assert.match(readFileSync(envPath, "utf8"), /^EMBEDDING_API_KEY=EMPTY$/m);
});

View file

@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
closeSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInstallSpawner } from "../dist/environment/host/install.js";
import {
openAppendLog,
pruneInstallLogs,
} from "../dist/environment/host/log.js";
import { createNodeRunner } from "../dist/environment/host/runner.js";
test("a timeout settles with an explicit error instead of hanging", async () => {
// The child ignores SIGTERM, so the grace path must force it and settle.
const run = createNodeRunner({ PATH: process.env.PATH });
const started = Date.now();
const result = await run(
process.execPath,
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
{ timeoutMs: 200 },
);
assert.match(result.error, /timed out after 200 ms/);
assert.ok(
Date.now() - started < 5_000,
"the probe must not wait for a process that never closes",
);
});
test("a child that exits on SIGTERM is still reported as a timeout", async () => {
const run = createNodeRunner({ PATH: process.env.PATH });
const result = await run(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 200 },
);
assert.match(result.error ?? "", /timed out after 200 ms/);
});
test("the real install spawner starts and stops a process", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-spawner-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const spawner = createInstallSpawner({ PATH: process.env.PATH });
const logPath = join(directory, "install.log");
const child = spawner.start({
exe: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
logPath,
});
assert.ok(child.pid !== undefined && child.pid > 1);
const exited = new Promise((resolve) => child.onExit(resolve));
child.kill();
const outcome = await Promise.race([
exited,
new Promise((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
]);
assert.notEqual(outcome, "timeout", "the install process must actually exit");
assert.match(readFileSync(logPath, "utf8"), /^$/);
});
test("an oversized log is rotated before the next append", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-log-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const path = join(directory, "install.log");
writeFileSync(path, "x".repeat(200));
const fd = openAppendLog(path, 100);
closeSync(fd);
assert.equal(readFileSync(`${path}.1`, "utf8").length, 200);
assert.equal(statSync(path).size, 0);
});
test("per-job install logs are pruned to the newest files", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-prune-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
for (let index = 0; index < 25; index += 1)
writeFileSync(join(directory, `install-${index}.log`), "x");
pruneInstallLogs(directory, 20);
const remaining = readdirSync(directory).filter((name) =>
name.endsWith(".log"),
);
assert.equal(remaining.length, 20);
});

View file

@ -0,0 +1,952 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer } from "node:http";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildStartCommand,
createServiceIO,
forgetService,
ownsService,
parseLocalEndpoint,
readServiceState,
serviceOwnership,
serviceStatus,
startService,
stopService,
writeServiceState,
} from "../dist/environment/host/service.js";
const ENDPOINT = "http://127.0.0.1:2334";
function fakeIO(overrides = {}) {
const calls = { killTree: [], force: [], spawn: [] };
const io = {
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
portOpen: async () => false,
async spawnService(options) {
calls.spawn.push(options);
return { pid: 4321 };
},
killTree(pid, force = false) {
calls.killTree.push(pid);
if (force) calls.force.push(pid);
},
probePid() {},
processImage: async () => "python.exe",
processCommandLine: async () => "",
exists: () => true,
platform: "win32",
...overrides,
};
return { io, calls };
}
/** The command line a launch for `statePath` would actually have. */
function commandFor(statePath, port = 2334) {
const state = readServiceState(statePath);
return `reme start workspace_dir=${state.workspaceDir} service.port=${port}`;
}
function tempState(t, prefix) {
const directory = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(directory, { recursive: true, force: true }));
return { directory, statePath: join(directory, "service.json") };
}
function record(statePath, overrides = {}) {
writeServiceState(statePath, {
pid: 777,
endpoint: ENDPOINT,
workspaceDir: join(statePath, ".."),
pythonPath: "D:\\env\\python.exe",
command: "reme start",
startedAt: "2026-10-27T00:00:00.000Z",
...overrides,
});
}
test("accepts only loopback HTTP endpoints and validates the port", () => {
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), {
host: "127.0.0.1",
port: 2334,
});
assert.deepEqual(parseLocalEndpoint("http://localhost:2333"), {
host: "127.0.0.1",
port: 2333,
});
// A trailing slash is the origin root and stays launchable.
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334/"), {
host: "127.0.0.1",
port: 2334,
});
assert.throws(() => parseLocalEndpoint("http://10.0.0.5:2333"));
assert.throws(() => parseLocalEndpoint("not-a-url"));
assert.throws(() => parseLocalEndpoint("http://127.0.0.1:0"));
// The plugin starts a plain HTTP service, so https on loopback can never be
// healthy; it must not be offered as manageable.
assert.throws(
() => parseLocalEndpoint("https://127.0.0.1:2333"),
/http:\/\//,
);
// The launch binds service.host=127.0.0.1, so an IPv6 loopback endpoint
// would start a service that can never answer it.
assert.throws(() => parseLocalEndpoint("http://[::1]:2333"), /IPv4/);
// The launch configures host and port only; a path prefix (a gateway that
// mounts ReMe under, say, `/reme`) cannot be served by it, so the startup
// health check at `/reme/health_check` would 404.
assert.throws(
() => parseLocalEndpoint("http://127.0.0.1:2334/reme"),
/root-path/,
);
assert.throws(
() => parseLocalEndpoint("http://localhost:2333/reme"),
/root-path/,
);
});
test("builds reme start with the workspace cwd contract", () => {
const command = buildStartCommand({
cliPath: "D:\\env\\Scripts\\reme.exe",
pythonPath: "D:\\env\\python.exe",
workspaceDir: "C:\\Users\\u\\.reme",
endpoint: "http://127.0.0.1:2334",
disableDreamCron: false,
exists: () => true,
});
assert.equal(command.exe, "D:\\env\\Scripts\\reme.exe");
assert.ok(command.args.includes("start"));
assert.ok(command.args.includes("workspace_dir=C:\\Users\\u\\.reme"));
assert.ok(command.args.includes("service.host=127.0.0.1"));
assert.ok(command.args.includes("service.port=2334"));
assert.ok(command.args.includes("enable_logo=false"));
assert.ok(!command.args.includes("jobs.dream_cron.enabled=false"));
const newer = buildStartCommand({
cliPath: "D:\\env\\Scripts\\reme.exe",
pythonPath: "D:\\env\\python.exe",
workspaceDir: "C:\\Users\\u\\.reme",
endpoint: "http://127.0.0.1:2334",
disableDreamCron: true,
exists: () => true,
});
assert.ok(newer.args.includes("jobs.dream_cron.enabled=false"));
});
test("falls back to the interpreter wrapper when the CLI is missing", () => {
const command = buildStartCommand({
cliPath: "",
pythonPath: "D:\\env\\python.exe",
workspaceDir: "C:\\Users\\u\\.reme",
endpoint: "http://127.0.0.1:2334",
disableDreamCron: false,
exists: () => false,
});
assert.equal(command.exe, "D:\\env\\python.exe");
assert.equal(command.args[0], "-c");
assert.match(String(command.args[1]), /sys\.argv/);
assert.match(String(command.args[1]), /from reme\.reme import main/);
assert.match(String(command.args[1]), /service\.port=2334/);
});
test("derives running, conflict and stopped phases", async (t) => {
const { directory, statePath } = tempState(t, "reme-service-");
const base = {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
};
const runningIo = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
});
let status = await serviceStatus(runningIo.io, base);
assert.equal(status.phase, "running");
assert.equal(status.managed, false);
assert.equal(status.version, "0.4.0.9");
record(statePath);
runningIo.io.processCommandLine = async () => commandFor(statePath);
status = await serviceStatus(runningIo.io, base);
assert.equal(status.phase, "running");
assert.equal(status.managed, true);
assert.equal(status.pid, 777);
const conflict = await serviceStatus(
fakeIO({ portOpen: async () => true }).io,
base,
);
assert.equal(conflict.phase, "conflict");
const stopped = await serviceStatus(fakeIO().io, base);
assert.equal(stopped.phase, "stopped");
});
test("a service on another endpoint is not reported as managed", async (t) => {
const { directory, statePath } = tempState(t, "reme-own-");
record(statePath, { endpoint: "http://127.0.0.1:2333" });
const { io } = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "running");
assert.equal(status.managed, false);
assert.equal(status.pid, undefined);
});
test("a loopback alias still identifies the service this plugin started", async (t) => {
const { directory, statePath } = tempState(t, "reme-alias-");
record(statePath, { endpoint: "http://localhost:2334/" });
const { io } = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
processCommandLine: async () => commandFor(statePath),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.managed, true);
assert.equal(status.pid, 777);
});
test("a conflict still identifies a provably owned service", async (t) => {
const { directory, statePath } = tempState(t, "reme-conflict-");
record(statePath);
const { io } = fakeIO({
portOpen: async () => true,
processCommandLine: async () => commandFor(statePath),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "conflict");
assert.equal(status.managed, true);
assert.equal(status.pid, 777);
});
test("an unreadable identity names the record the status cannot clear", async (t) => {
// The process is alive, but its command line cannot be read: ownership stays
// unproven, so the process is never signalled. The record is nevertheless
// the only way out of a port the tab cannot clear, so it travels with the
// status instead of waiting for a refusal the card cannot trigger.
const { directory, statePath } = tempState(t, "reme-unverified-");
record(statePath);
const { io } = fakeIO({
portOpen: async () => true,
processCommandLine: async () => "",
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "conflict");
assert.equal(status.managed, false, "an unproven identity is not ours");
assert.equal(status.pid, undefined, "and no pid is claimed either");
assert.deepEqual(status.record, { endpoint: ENDPOINT, pid: 777 });
});
test("a record that names no backend stays out of the status", async (t) => {
// A record for another endpoint says nothing about this one, so it is not
// offered as a recovery here.
const { directory, statePath } = tempState(t, "reme-other-record-");
record(statePath, { endpoint: "http://127.0.0.1:2333" });
const { io } = fakeIO({
portOpen: async () => true,
processCommandLine: async () => "",
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "conflict");
assert.equal(status.record, undefined);
});
test("a recycled pid that is no longer the service is not managed", async (t) => {
const { statePath } = tempState(t, "reme-pid-");
record(statePath);
const { io } = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
processImage: async () => "node.exe",
});
assert.equal(
await ownsService(io, readServiceState(statePath), ENDPOINT),
false,
);
const { io: gone } = fakeIO({
probePid() {
throw new Error("ESRCH");
},
});
assert.equal(
await ownsService(gone, readServiceState(statePath), ENDPOINT),
false,
);
// Ownership guards a destructive action: an image that cannot be read is
// not evidence that the pid is ours, and a prefix match would accept e.g.
// pycharm64.exe.
for (const image of ["", " ", "pycharm64.exe", "python-config"]) {
const { io: unreadable } = fakeIO({ processImage: async () => image });
assert.equal(
await ownsService(unreadable, readServiceState(statePath), ENDPOINT),
false,
`image ${JSON.stringify(image)} must not be owned`,
);
}
// A Python image whose command line describes another launch is foreign,
// not ours; an unreadable command line is unproven, not ownership.
const { io: otherLaunch } = fakeIO({
processCommandLine: async () => "python -m http.server 2334",
});
assert.equal(
await serviceOwnership(otherLaunch, readServiceState(statePath), ENDPOINT),
"foreign",
);
const { io: unproven } = fakeIO();
assert.equal(
await serviceOwnership(unproven, readServiceState(statePath), ENDPOINT),
"unknown",
);
assert.equal(
await ownsService(unproven, readServiceState(statePath), ENDPOINT),
false,
);
// Matching is boundary-aware: a longer workspace path or port is another
// launch, not this one. A path separator is not a boundary either, so a
// record for `...\reme-x` never claims a service started in `...\reme-x\sub`.
const state = readServiceState(statePath);
for (const command of [
`reme start workspace_dir=${state.workspaceDir}2 service.port=2334`,
`reme start workspace_dir=${state.workspaceDir}\\sub service.port=2334`,
`reme start workspace_dir=${state.workspaceDir}/sub service.port=2334`,
`reme start workspace_dir=${state.workspaceDir} service.port=23340`,
]) {
const { io: longer } = fakeIO({
processCommandLine: async () => command,
});
assert.equal(
await serviceOwnership(longer, state, ENDPOINT),
"foreign",
command,
);
}
});
test("recognizes the Windows wrapper command line with escaped quotes", async (t) => {
const { statePath } = tempState(t, "reme-wrapper-");
record(statePath);
const state = readServiceState(statePath);
// What Windows shows for `python -c <wrapper>`: the JSON quotes are escaped
// as `\"` and each path separator appears doubled.
const escaped = state.workspaceDir.replaceAll("\\", "\\\\");
const command = `python -c "import sys;sys.argv=[\\"reme\\",\\"start\\",\\"workspace_dir=${escaped}\\",\\"service.port=2334\\"];from reme.reme import main"`;
const { io } = fakeIO({ processCommandLine: async () => command });
assert.equal(await serviceOwnership(io, state, ENDPOINT), "owned");
});
test("a prefixed legacy record stays identifiable, so it can be stopped", async (t) => {
const { statePath } = tempState(t, "reme-prefix-");
// A record from a version that still launched a prefixed loopback endpoint.
// The launch rule has changed, but the process it started is still ours and
// must not become an unowned orphan just because the endpoint is not
// launchable any more.
const endpoint = "http://127.0.0.1:2334/reme";
record(statePath, { endpoint });
const { io, calls } = fakeIO({
processCommandLine: async () => commandFor(statePath),
probePid() {
// The record's process only disappears once it was asked to stop.
if (calls.killTree.length === 0) return;
const error = new Error("ESRCH");
error.code = "ESRCH";
throw error;
},
});
const state = readServiceState(statePath);
assert.equal(await serviceOwnership(io, state, endpoint), "owned");
assert.equal(await ownsService(io, state, endpoint), true);
// Because ownership is proven, Stop acts on the recorded pid instead of
// refusing it as foreign, and the record is dropped once it exited.
const stopped = await stopService(io, {
statePath,
endpoint,
timeoutMs: 50,
pollMs: 5,
});
assert.equal(stopped.stopped, true);
assert.deepEqual(calls.killTree, [777]);
assert.equal(readServiceState(statePath), undefined);
});
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
const { directory, statePath } = tempState(t, "reme-dead-");
record(statePath);
const { io } = fakeIO({
probePid() {
throw new Error("ESRCH");
},
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "stopped");
assert.equal(status.pid, undefined);
assert.equal(readServiceState(statePath), undefined);
});
test("a probe denied by permissions is not treated as a dead process", async (t) => {
const { directory, statePath } = tempState(t, "reme-eperm-");
record(statePath);
const denied = new Error("operation not permitted");
denied.code = "EPERM";
const { io } = fakeIO({
probePid() {
throw denied;
},
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
// The process exists but this user cannot signal it (e.g. an elevated
// service): the record must survive so it is not orphaned.
assert.equal(status.phase, "stopped");
assert.equal(status.pid, 777);
assert.equal(readServiceState(statePath)?.pid, 777);
});
test("starts detached with the GBK-safe environment and records state", async (t) => {
const { directory, statePath } = tempState(t, "reme-start-");
const { io, calls } = fakeIO();
const result = await startService(io, {
endpoint: ENDPOINT,
workspaceDir: directory,
pythonPath: "D:\\env\\python.exe",
cliPath: "D:\\env\\Scripts\\reme.exe",
disableDreamCron: true,
statePath,
logPath: join(directory, "service.log"),
env: { PATH: "x" },
});
assert.equal(result.started, true);
assert.equal(result.pid, 4321);
const spawned = calls.spawn[0];
assert.equal(spawned.cwd, directory);
assert.equal(spawned.env.PYTHONUTF8, "1");
assert.equal(spawned.env.NO_PROXY, "localhost,127.0.0.1,::1");
const persisted = readServiceState(statePath);
assert.equal(persisted?.pid, 4321);
assert.equal(persisted?.workspaceDir, directory);
});
test("records nothing when the spawn fails", async (t) => {
const { directory, statePath } = tempState(t, "reme-spawn-");
const { io } = fakeIO({
async spawnService() {
throw new Error("spawn ENOENT");
},
});
const result = await startService(io, {
endpoint: ENDPOINT,
workspaceDir: directory,
pythonPath: "D:\\env\\python.exe",
cliPath: "D:\\missing\\reme.exe",
disableDreamCron: false,
statePath,
logPath: join(directory, "service.log"),
env: {},
});
assert.equal(result.started, false);
assert.match(result.error, /ENOENT/);
assert.equal(readServiceState(statePath), undefined);
});
test("missing and malformed service records remain rebuildable", (t) => {
const { statePath } = tempState(t, "reme-malformed-record-");
assert.equal(readServiceState(statePath), undefined);
for (const text of ["{", "{}", '{"endpoint":7}']) {
writeFileSync(statePath, text);
assert.equal(readServiceState(statePath), undefined);
}
});
test("a launch never overwrites a service recorded before its spawn acknowledgement", async (t) => {
const { directory, statePath } = tempState(t, "reme-publish-race-");
let running = true;
let group = true;
const { io, calls } = fakeIO({
async spawnService() {
record(statePath, { pid: 777 });
return { pid: 4321 };
},
killTree(pid, force) {
calls.killTree.push(pid);
assert.equal(force, true);
running = false;
setTimeout(() => {
group = false;
}, 5);
},
probePid() {
if (!running) throw Object.assign(new Error("gone"), { code: "ESRCH" });
},
groupAlive: () => group,
});
const result = await startService(io, {
endpoint: ENDPOINT,
workspaceDir: directory,
pythonPath: "/env/bin/python",
cliPath: "",
disableDreamCron: false,
statePath,
logPath: join(directory, "service.log"),
env: {},
});
assert.equal(result.started, false);
assert.equal(result.code, "start-failed");
assert.equal(
group,
false,
"the unrecorded process group must exit before failure is reported",
);
assert.deepEqual(
calls.killTree,
[4321],
"only the new unrecorded launch is stopped",
);
assert.equal(readServiceState(statePath)?.pid, 777);
});
test("stops the managed pid and clears the state file after it exits", async (t) => {
const { directory, statePath } = tempState(t, "reme-stop-");
record(statePath, { pid: 999, workspaceDir: directory });
const { io, calls } = fakeIO({
processCommandLine: async () => commandFor(statePath),
});
let running = true;
io.probePid = () => {
if (!running) throw new Error("ESRCH");
};
io.killTree = (pid) => {
calls.killTree.push(pid);
running = false;
};
const stopped = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 100,
pollMs: 5,
});
assert.deepEqual(stopped, { stopped: true, pid: 999 });
assert.deepEqual(calls.killTree, [999]);
assert.equal(readServiceState(statePath), undefined);
assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), {
stopped: false,
reason: "No service started by this plugin",
code: "no-record",
});
});
test("escalates to SIGKILL and keeps the record when the service never exits", async (t) => {
const { directory, statePath } = tempState(t, "reme-stubborn-");
record(statePath, { pid: 999, workspaceDir: directory });
const { io, calls } = fakeIO({
processCommandLine: async () => commandFor(statePath),
});
const stopped = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 10,
forceTimeoutMs: 10,
pollMs: 2,
});
assert.equal(stopped.stopped, false);
assert.match(stopped.reason, /did not exit/);
// The record survives, so it is named: the tab can offer to forget exactly
// this record.
assert.deepEqual(stopped.record, { endpoint: ENDPOINT, pid: 999 });
assert.deepEqual(calls.killTree, [999, 999]);
assert.deepEqual(calls.force, [999]);
// The pid survives in the record, so Stop can be tried again later.
assert.equal(readServiceState(statePath)?.pid, 999);
});
test("refuses to stop a service recorded for another endpoint", async (t) => {
const { statePath } = tempState(t, "reme-refuse-");
record(statePath, { endpoint: "http://127.0.0.1:2333" });
const { io, calls } = fakeIO();
const stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(stopped.stopped, false);
assert.match(stopped.reason, /does not belong/);
assert.deepEqual(calls.killTree, []);
assert.ok(readServiceState(statePath), "the foreign record is left in place");
});
for (const exits of [true, false]) {
test(`a parent-first exit ${
exits ? "waits for" : "retains"
} its process group`, async (t) => {
const { directory, statePath } = tempState(t, "reme-stop-group-");
record(statePath, { pid: 999, workspaceDir: directory });
let parent = true;
let group = true;
const { io, calls } = fakeIO({
platform: "linux",
processCommandLine: async () => commandFor(statePath),
probePid() {
if (!parent) throw Object.assign(new Error("gone"), { code: "ESRCH" });
},
groupAlive: () => group,
killTree(pid, force = false) {
calls.killTree.push(pid);
parent = false;
if (force) {
calls.force.push(pid);
group = !exits;
}
},
});
const result = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 10,
forceTimeoutMs: 10,
pollMs: 2,
});
assert.deepEqual(calls.force, [999], "the surviving child gets SIGKILL");
assert.equal(result.stopped, exits);
if (exits) {
assert.equal(readServiceState(statePath), undefined);
} else {
assert.equal(result.code, "not-exited");
const status = await serviceStatus(io, {
statePath,
endpoint: ENDPOINT,
logPath: join(directory, "service.log"),
});
assert.equal(readServiceState(statePath)?.pid, 999);
assert.equal(status.pid, 999);
assert.equal(
await serviceOwnership(io, readServiceState(statePath), ENDPOINT),
"unknown",
);
// A later stop no longer has a leader whose identity it can verify.
const later = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(later.code, "unverified");
assert.deepEqual(
calls.force,
[999],
"an unverified group is not signalled again",
);
}
});
}
test("a stop never signals a record replaced during the ownership probe", async (t) => {
const { statePath } = tempState(t, "reme-stop-race-");
record(statePath);
const command = commandFor(statePath);
const { io, calls } = fakeIO({
processCommandLine: async () => {
record(statePath, { pid: 888, startedAt: "new" });
return command;
},
});
const result = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(result.code, "record-changed");
assert.deepEqual(calls.killTree, []);
assert.equal(readServiceState(statePath)?.pid, 888);
});
test("finishing an old stop does not erase a newer service record", async (t) => {
const { statePath } = tempState(t, "reme-stop-new-record-");
record(statePath);
let running = true;
const { io } = fakeIO({
processCommandLine: async () => commandFor(statePath),
probePid() {
if (!running) throw Object.assign(new Error("gone"), { code: "ESRCH" });
},
killTree() {
running = false;
record(statePath, { pid: 888, startedAt: "new" });
},
});
const result = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(result.stopped, true);
assert.equal(readServiceState(statePath)?.pid, 888);
});
test("refuses to stop a process whose identity cannot be verified", async (t) => {
const { statePath } = tempState(t, "reme-unknown-");
record(statePath);
const { io, calls } = fakeIO(); // image readable, command line not
const stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(stopped.stopped, false);
assert.match(stopped.reason, /could not be verified/);
// The record is named, so the client can offer the record-only way out.
assert.deepEqual(stopped.record, { endpoint: ENDPOINT, pid: 777 });
assert.deepEqual(calls.killTree, []);
assert.equal(readServiceState(statePath)?.pid, 777);
});
test("forgetting refuses a record that changed since it was inspected", async (t) => {
const { statePath } = tempState(t, "reme-changed-");
record(statePath, { pid: 999 });
const { calls } = fakeIO();
const changed = await forgetService({
statePath,
endpoint: ENDPOINT,
expectPid: 777,
});
assert.equal(changed.forgotten, false);
assert.equal(changed.code, "record-changed");
assert.deepEqual(changed.record, { endpoint: ENDPOINT, pid: 999 });
// The record is still there: dropping it would orphan the process.
assert.equal(readServiceState(statePath)?.pid, 999);
assert.deepEqual(calls.killTree, []);
const forgotten = await forgetService({
statePath,
endpoint: ENDPOINT,
expectPid: 999,
});
assert.equal(forgotten.forgotten, true);
assert.equal(readServiceState(statePath), undefined);
});
/* ------------------------------------------------------------------ */
/* Real IO: the pieces the fake adapter above cannot vouch for. */
/* ------------------------------------------------------------------ */
async function listening(handler, t) {
const server = createServer(handler);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => {
// fetch keeps pooled sockets alive; close them so the next test is not
// racing a server that is still draining.
server.closeAllConnections?.();
server.close();
});
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
/**
* Windows may keep a just-killed process's cwd/log handle for a moment, so an
* immediate recursive remove can fail with EBUSY and fail the whole test.
* Only EBUSY/ENOTEMPTY are transient; anything else (and a persistent lock)
* fails loudly instead of leaking a temporary directory.
*/
async function removeDirectory(path) {
for (let attempt = 0; ; attempt += 1) {
try {
rmSync(path, { recursive: true, force: true });
return;
} catch (error) {
const code = error?.code;
if ((code !== "EBUSY" && code !== "ENOTEMPTY") || attempt >= 19)
throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
test("real IO accepts only a ReMe health report", async (t) => {
// One server for every case: several servers plus the 3 s probe timeout made
// this test fail under parallel load.
let authorization = "";
const base = await listening((request, response) => {
response.setHeader("Content-Type", "application/json");
const path = String(request.url ?? "").replace(/\/health_check$/, "");
if (path === "/reme")
response.end(
JSON.stringify({
success: true,
metadata: {
health: { version: "0.4.2.1", healthy: true, components: {} },
},
}),
);
else if (path === "/partial")
// A health object that does not parse is not a ReMe health report: the
// shared client requires the version, the health flag and the
// components, so a generic `{}` never passes.
response.end(
JSON.stringify({
success: true,
metadata: { health: { version: "0.4.2.1" } },
}),
);
else if (path === "/failing") {
response.statusCode = 503;
response.end("{}");
} else if (path === "/unhappy")
response.end(JSON.stringify({ success: false }));
else if (path === "/guarded") {
authorization = String(request.headers.authorization ?? "");
response.end("{}");
} else response.end("{}");
}, t);
const io = createServiceIO();
const good = await io.fetchHealth(`${base}/reme`, "");
assert.equal(good.ok, true);
assert.equal(good.version, "0.4.2.1");
assert.equal(good.code, undefined);
const empty = await io.fetchHealth(`${base}/generic`, "");
assert.equal(empty.ok, false);
assert.match(empty.error, /ReMe health report/);
// The client localizes the code instead of showing this English sentence.
assert.equal(empty.code, "not-reme");
// A health report that does not carry the expected fields is not one.
const partial = await io.fetchHealth(`${base}/partial`, "");
assert.equal(partial.ok, false);
assert.equal(partial.code, "not-reme");
const httpError = await io.fetchHealth(`${base}/failing`, "");
assert.equal(httpError.code, "http-status");
const reported = await io.fetchHealth(`${base}/unhappy`, "");
assert.equal(reported.code, "reme-error");
// The Authorization header is actually sent when a token is configured.
await io.fetchHealth(`${base}/guarded`, "secret-token");
assert.equal(authorization, "Bearer secret-token");
});
test("real IO spawns, identifies and stops a detached process", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-real-service-"));
t.after(() => removeDirectory(directory));
const io = createServiceIO();
const { pid } = await io.spawnService({
exe: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
cwd: directory,
logPath: join(directory, "service.log"),
env: process.env,
});
assert.ok(pid !== undefined && pid > 1);
assert.doesNotThrow(() => io.probePid(pid));
assert.doesNotMatch(await io.processImage(pid), /^$/);
assert.match(await io.processCommandLine(pid), /node/i);
io.killTree(pid);
const deadline = Date.now() + 5_000;
let running = true;
while (running && Date.now() < deadline) {
try {
io.probePid(pid);
await new Promise((resolve) => setTimeout(resolve, 50));
} catch {
running = false;
}
}
assert.equal(running, false, "the detached process must actually exit");
});
test(
"real POSIX stop kills a listening child after its parent exits",
{
skip: process.platform === "win32",
timeout: 15_000,
},
async (t) => {
const { directory, statePath } = tempState(t, "reme-real-group-");
const io = createServiceIO();
const logPath = join(directory, "service.log");
const childCode = `
process.on('SIGTERM', () => {});
const server = require('node:net').createServer(socket => socket.end());
server.listen(0, '127.0.0.1', () => {
console.log(JSON.stringify({ port: server.address().port, pid: process.pid }));
});
`;
const parentCode = `
process.on('SIGTERM', () => process.exit(0));
const child = require('node:child_process').spawn(process.execPath,
['-e', ${JSON.stringify(
childCode,
)}], { stdio: ['ignore', 'pipe', 'ignore'] });
child.stdout.pipe(process.stdout);
`;
const { pid } = await io.spawnService({
exe: process.execPath,
args: [
"-e",
parentCode,
`workspace_dir=${directory}`,
"service.port=2334",
],
cwd: directory,
logPath,
env: process.env,
});
const kill = io.killTree;
t.after(() => kill(pid, true));
let ready;
const deadline = Date.now() + 5_000;
while (!ready && Date.now() < deadline) {
try {
ready = JSON.parse(readFileSync(logPath, "utf8").trim());
} catch {
/* not listening yet */
}
if (!ready) await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.ok(ready, "the child listener must be ready before signalling");
assert.equal(await io.portOpen("127.0.0.1", ready.port), true);
record(statePath, { pid, workspaceDir: directory });
// The fixture uses Node instead of Python; spawn, command line, signals,
// group liveness and socket checks still use the real service adapter.
io.processImage = async () => "python";
let escalated = false;
io.killTree = (target, force) => {
if (force) {
escalated = true;
assert.throws(() => io.probePid(pid), { code: "ESRCH" });
assert.doesNotThrow(() => io.probePid(ready.pid));
}
kill(target, force);
};
const result = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 200,
forceTimeoutMs: 2_000,
pollMs: 20,
});
assert.equal(
escalated,
true,
"parent exit must not bypass child termination",
);
assert.equal(await io.portOpen("127.0.0.1", ready.port), false);
// Some container init processes leave orphaned zombies unreaped. A group
// still visible to signal 0 conservatively retains its record.
if (result.stopped) {
assert.equal(io.groupAlive(pid), false);
assert.equal(readServiceState(statePath), undefined);
} else {
assert.equal(result.code, "not-exited");
assert.equal(readServiceState(statePath)?.pid, pid);
}
},
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,617 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
envSummary,
LLM_PROTOCOLS,
llmProbeSnippet,
llmProtocolFor,
maskSecret,
mergeEnvFile,
parseEnvFile,
parseLlmTest,
resolveWorkspaceDir,
testLlm,
validateEnvUpdate,
} from "../dist/environment/host/workspace.js";
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
import { MANAGED_KEYS } from "../dist/environment/env-fields.js";
/** A manager whose process work is replaced by facts. */
function managerIn(t) {
const directory = mkdtempSync(join(tmpdir(), "reme-env-save-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const manager = new ReMeEnvManager(
{ DSH_HOME: directory },
{
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
portOpen: async () => false,
spawnService: async () => ({ pid: 1 }),
killTree() {},
probePid() {},
processImage: async () => "python.exe",
processCommandLine: async () => "",
exists: () => true,
platform: "win32",
},
);
return { manager, directory };
}
test("resolves the workspace directory with ~/.reme defaults", () => {
const home = process.platform === "win32" ? "C:\\Users\\u" : "/home/u";
assert.equal(resolveWorkspaceDir("", home), join(home, ".reme"));
assert.equal(resolveWorkspaceDir("~/.reme", home), join(home, ".reme"));
assert.equal(resolveWorkspaceDir("~", home), home);
assert.throws(() => resolveWorkspaceDir("relative/dir", home));
});
test("parses KEY=VALUE lines tolerating comments, blanks and quotes", () => {
const parsed = parseEnvFile(
[
"# service credentials",
"",
"LLM_BACKEND=openai",
'LLM_MODEL_NAME="qwen3.7-plus"',
"LLM_API_KEY='sk-test'",
"UNKNOWN_KEY=kept",
"BROKEN LINE",
].join("\n"),
);
assert.deepEqual(parsed, {
LLM_BACKEND: "openai",
LLM_MODEL_NAME: "qwen3.7-plus",
LLM_API_KEY: "sk-test",
UNKNOWN_KEY: "kept",
});
});
test("merges updates while preserving comments, order and unknown keys", () => {
const existing = [
"# managed by hand",
"LLM_BACKEND=openai",
"LLM_API_KEY=old-key",
"",
"EMBEDDING_API_KEY=EMPTY",
"# keep me",
].join("\n");
const merged = mergeEnvFile(existing, {
LLM_API_KEY: "new-key",
LLM_BASE_URL: "https://example.com/v1",
});
assert.match(merged, /^# managed by hand$/m);
assert.match(merged, /^LLM_API_KEY=new-key$/m);
assert.doesNotMatch(merged, /old-key/);
assert.match(merged, /^EMBEDDING_API_KEY=EMPTY$/m);
assert.match(merged, /^# keep me$/m);
assert.match(merged, /^LLM_BASE_URL=https:\/\/example\.com\/v1$/m);
assert.ok(merged.endsWith("\n"));
// Idempotent updates do not duplicate keys.
assert.equal(
(
mergeEnvFile(merged, { LLM_API_KEY: "new-key" }).match(
/^LLM_API_KEY=/gm,
) ?? []
).length,
1,
);
});
test("a duplicated key is rewritten everywhere, so parsing agrees", () => {
const existing = [
"LLM_API_KEY=old-key",
"LLM_MODEL_NAME=qwen3.7-plus",
"LLM_API_KEY=older-key",
].join("\n");
const merged = mergeEnvFile(existing, { LLM_API_KEY: "new-key" });
assert.equal((merged.match(/^LLM_API_KEY=new-key$/gm) ?? []).length, 2);
// Parsing keeps the last occurrence, and that occurrence is the new value.
assert.equal(parseEnvFile(merged)["LLM_API_KEY"], "new-key");
assert.equal((merged.match(/^LLM_API_KEY=/gm) ?? []).length, 2);
});
test("keeps the file's own line ending", () => {
const merged = mergeEnvFile(
"LLM_BACKEND=openai\r\nEMBEDDING_API_KEY=EMPTY\r\n",
{
LLM_BACKEND: "dashscope",
},
);
assert.match(merged, /^LLM_BACKEND=dashscope\r$/m);
assert.equal(merged.includes("\n\n"), false);
assert.ok(merged.endsWith("\r\n"));
assert.equal(mergeEnvFile("", { A: "1" }), "A=1\n");
});
test("masks secrets down to the last four characters", () => {
assert.equal(maskSecret("sk-abcdefgh"), "••••efgh");
assert.equal(maskSecret("abc"), "••••");
assert.equal(maskSecret(""), "");
});
test("summaries expose masked secrets and plain non-secret values", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-env-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const path = join(directory, ".env");
writeFileSync(
path,
"LLM_MODEL_NAME=qwen3.7-plus\nLLM_API_KEY=sk-super-secret\n",
);
const summary = envSummary(path, directory);
assert.equal(summary.exists, true);
assert.equal(summary.envPath, path);
const model = summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME");
assert.equal(model.value, "qwen3.7-plus");
assert.equal(model.secret, false);
const key = summary.entries.find((entry) => entry.key === "LLM_API_KEY");
assert.equal(key.set, true);
assert.equal(key.secret, true);
assert.equal(key.value, "");
assert.equal(key.masked, "••••cret");
assert.doesNotMatch(JSON.stringify(summary), /super-secret/);
});
test("the summary lists the managed keys in the shared order and masks the secrets", (t) => {
// The Host projection and the browser form read the same key list, so the
// summary has to name the same keys in the same order.
const directory = mkdtempSync(join(tmpdir(), "reme-env-order-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const path = join(directory, ".env");
writeFileSync(path, "EMBEDDING_API_KEY=EMPTY\nLLM_API_KEY=sk-super-secret\n");
const summary = envSummary(path, directory);
assert.deepEqual(
summary.entries.map((entry) => entry.key),
[...MANAGED_KEYS],
);
const key = summary.entries.find((entry) => entry.key === "LLM_API_KEY");
assert.equal(key.secret, true);
assert.equal(key.set, true);
assert.equal(key.value, "");
assert.equal(key.masked, "••••cret");
assert.doesNotMatch(JSON.stringify(summary), /super-secret/);
// A key the file does not hold is reported as unset, not as missing.
const base = summary.entries.find((entry) => entry.key === "LLM_BASE_URL");
assert.equal(base.set, false);
assert.equal(base.value, "");
});
test("validates the embedding key that aborts ReMe 0.4.0.x startup", () => {
assert.throws(() =>
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "" }),
);
assert.throws(() =>
validateEnvUpdate({ LLM_API_KEY: "", EMBEDDING_API_KEY: "EMPTY" }),
);
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "EMPTY" });
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "real-key" });
});
test("parses LLM test results from noisy python output", () => {
assert.deepEqual(
parseLlmTest(
'warn\n{"ok": true, "status": 200, "latencyMs": 321, "error": ""}\n',
),
{ ok: true, status: 200, latencyMs: 321, error: "" },
);
assert.deepEqual(parseLlmTest("no json"), {
ok: false,
status: 0,
latencyMs: 0,
error: "LLM test produced no result",
});
});
test("runs the LLM probe inside the workspace and classifies failures", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-llm-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const calls = [];
const io = {
run: async (exe, args, options) => {
calls.push({ exe, args, options });
const stdout =
calls.length === 1
? '{"ok": true, "status": 200, "latencyMs": 120, "error": ""}\n'
: '{"ok": false, "status": 401, "latencyMs": 40, "error": "HTTP 401"}\n';
return { code: 0, stdout, stderr: "" };
},
};
const ok = await testLlm(io, {
workspaceDir: directory,
pythonPath: "C:\\env\\python.exe",
});
assert.equal(ok.ok, true);
assert.equal(ok.latencyMs, 120);
assert.equal(calls[0].options.cwd, directory);
assert.equal(calls[0].exe, "C:\\env\\python.exe");
const denied = await testLlm(io, {
workspaceDir: directory,
pythonPath: "C:\\env\\python.exe",
});
assert.equal(denied.ok, false);
assert.match(denied.error, /LLM_API_KEY/);
const broken = await testLlm(
{
run: async () => ({
code: null,
stdout: "",
stderr: "",
error: "spawn ENOENT",
}),
},
{ workspaceDir: directory, pythonPath: "C:\\missing\\python.exe" },
);
assert.equal(broken.ok, false);
assert.match(broken.error, /ENOENT/);
});
test("the LLM probe speaks the protocol of the selected backend", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-llm-backend-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const calls = [];
const io = {
run: async (exe, args, options) => {
calls.push({ exe, args, options });
return {
code: 0,
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
};
const probe = async (backend) => {
writeFileSync(
join(directory, ".env"),
[
`LLM_BACKEND=${backend}`,
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
"LLM_BASE_URL=https://provider.invalid",
].join("\n"),
);
calls.length = 0;
await testLlm(io, {
workspaceDir: directory,
pythonPath: "C:\\env\\python.exe",
});
return String(calls[0]?.args[1] ?? "");
};
// The OpenAI-compatible providers keep the OpenAI-style request.
const openai = await probe("openai");
assert.match(openai, /\/chat\/completions/);
assert.match(openai, /"Authorization"/);
assert.match(openai, /Bearer /);
// A native provider must not be probed with the OpenAI protocol: that would
// report a working configuration as broken.
const anthropic = await probe("anthropic");
assert.match(anthropic, /\/v1\/messages/);
assert.match(anthropic, /"x-api-key"/);
assert.match(anthropic, /anthropic-version/);
assert.doesNotMatch(anthropic, /\/chat\/completions/);
const gemini = await probe("gemini");
assert.match(gemini, /generateContent/);
assert.match(gemini, /"x-goog-api-key"/);
assert.doesNotMatch(gemini, /\/chat\/completions/);
const ollama = await probe("ollama");
assert.match(ollama, /\/api\/chat/);
assert.doesNotMatch(ollama, /"Authorization"/);
// An unknown backend falls back to ReMe's default rather than failing.
const unknown = await probe("something-else");
assert.match(unknown, /\/chat\/completions/);
});
test("the probe builds a valid request for every backend", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-llm-request-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const model = "test-model";
for (const backend of Object.keys(LLM_PROTOCOLS)) {
const protocol = llmProtocolFor(backend);
// The body is a JSON template: the placeholder is replaced with the
// JSON-quoted model name, so the result must still be valid JSON.
const body = protocol.body.replace("{model}", JSON.stringify(model));
const parsed = JSON.parse(body);
assert.equal(typeof parsed, "object", `${backend} body is JSON`);
if (protocol.path.includes("{model}")) {
// The model belongs in the path, URL-encoded.
assert.ok(!protocol.body.includes("{model}"), `${backend} body`);
const url = `https://provider.invalid${protocol.path.replace(
"{model}",
encodeURIComponent(model),
)}`;
assert.ok(url.includes(model), `${backend} url carries the model`);
} else {
assert.ok(
protocol.body.includes("{model}"),
`${backend} carries the model in the body`,
);
}
// The URL is the base plus the provider's path, exactly as the provider's
// own SDK builds it: nothing is trimmed away, so a base that already
// carries the version suffix fails in the probe just like it fails in the
// service.
assert.ok(
protocol.path.startsWith("/"),
`${backend} path is appended to the base`,
);
const snippet = llmProbeSnippet(protocol);
assert.doesNotMatch(
snippet,
/base\s*\[|base\.endswith\(|base\.removesuffix\(/,
`${backend} does not rewrite the configured base`,
);
}
});
test("a backend that ignores LLM_BASE_URL is probed with its own endpoint", async (t) => {
// ReMe's Gemini and Ollama credentials do not read LLM_BASE_URL, so the
// probe must not read it either: a stale value left in the .env would send
// the probe to a host the service never calls.
const directory = mkdtempSync(join(tmpdir(), "reme-llm-base-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const calls = [];
const io = {
run: async (exe, args) => {
calls.push({ exe, args });
return {
code: 0,
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
};
const probe = async (backend, baseUrl) => {
writeFileSync(
join(directory, ".env"),
[
`LLM_BACKEND=${backend}`,
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
...(baseUrl === undefined ? [] : [`LLM_BASE_URL=${baseUrl}`]),
].join("\n"),
);
calls.length = 0;
await testLlm(io, {
workspaceDir: directory,
pythonPath: "C:\\env\\python.exe",
});
return String(calls[0]?.args[1] ?? "");
};
// Without a base URL the provider's own endpoint is used.
const gemini = await probe("gemini");
assert.match(gemini, /generativelanguage\.googleapis\.com/);
assert.doesNotMatch(gemini, /env\.get\("LLM_BASE_URL"/);
// A stale base URL in the .env is ignored, exactly as ReMe ignores it.
const stale = await probe("ollama", "https://stale.invalid");
assert.match(stale, /localhost:11434/);
assert.doesNotMatch(stale, /stale\.invalid/);
// xAI is the same case: its credential carries an api host, not a base URL.
const xai = await probe("xai", "https://stale.invalid");
assert.match(xai, /api\.x\.ai/);
assert.doesNotMatch(xai, /stale\.invalid/);
assert.doesNotMatch(xai, /env\.get\("LLM_BASE_URL"/);
// Ollama needs neither a base URL nor a key.
const ollama = await probe("ollama");
assert.match(ollama, /localhost:11434/);
assert.match(ollama, /if False and not key/);
// The OpenAI-compatible providers do read LLM_BASE_URL, so it stays required.
const openai = await probe("openai");
assert.match(openai, /missing\.append\("LLM_BASE_URL"\)/);
assert.match(openai, /env\.get\("LLM_BASE_URL"/);
});
test("a cleared backend is saved as the default, never as an empty value", (t) => {
// ReMe resolves `${LLM_BACKEND:-openai}` only when the variable is absent.
// An empty value would name no backend, and the service would refuse to
// start, so the saved file must hold the default.
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(
envPath,
"LLM_BACKEND=dashscope\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n",
);
manager.writeEnv(directory, { LLM_BACKEND: "" });
const text = readFileSync(envPath, "utf8");
assert.match(text, /^LLM_BACKEND=openai$/m);
assert.doesNotMatch(text, /^LLM_BACKEND=$/m);
// The other keys are untouched.
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
assert.match(text, /^EMBEDDING_API_KEY=EMPTY$/m);
});
test("a cleared embedding backend is saved as the default", (t) => {
// ReMe 0.4.0.x declares `as_embedding` with
// `backend: ${EMBEDDING_BACKEND:-openai}` and instantiates it, so an empty
// value names no backend there either.
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(
envPath,
"EMBEDDING_BACKEND=gemini\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n",
);
manager.writeEnv(directory, { EMBEDDING_BACKEND: "" });
const text = readFileSync(envPath, "utf8");
assert.match(text, /^EMBEDDING_BACKEND=openai$/m);
assert.doesNotMatch(text, /^EMBEDDING_BACKEND=$/m);
assert.match(text, /^LLM_API_KEY=sk-keep$/m);
});
test("the probe refuses a .env whose backend is empty", async (t) => {
// An explicitly empty backend names no backend, and ReMe would start with
// none at all. The honest answer is an incomplete configuration, and no
// request is sent.
const directory = mkdtempSync(join(tmpdir(), "reme-llm-empty-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
writeFileSync(
join(directory, ".env"),
[
"LLM_BACKEND=",
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
"LLM_BASE_URL=https://provider.invalid",
].join("\n"),
);
const calls = [];
const result = await testLlm(
{
run: async (exe, args, options) => {
calls.push({ exe, args, options });
return {
code: 0,
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
},
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
);
assert.deepEqual(result, {
ok: false,
status: 0,
latencyMs: 0,
error: "LLM config incomplete (LLM_BACKEND)",
});
assert.equal(calls.length, 0, "the snippet is not run");
});
test("the probe refuses a known backend written in the wrong case", async (t) => {
// ReMe's backend registry is case-sensitive, so "OpenAI" is refused as an
// unregistered backend. Probing it would test a configuration the service
// cannot use.
const directory = mkdtempSync(join(tmpdir(), "reme-llm-case-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
writeFileSync(
join(directory, ".env"),
[
"LLM_BACKEND=OpenAI",
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
"LLM_BASE_URL=https://provider.invalid",
].join("\n"),
);
const calls = [];
const result = await testLlm(
{
run: async (exe, args, options) => {
calls.push({ exe, args, options });
return {
code: 0,
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
},
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
);
assert.equal(result.ok, false);
assert.match(result.error, /LLM_BACKEND/);
assert.match(result.error, /openai/, "the message names the spelling to use");
assert.equal(calls.length, 0, "the snippet is not run");
});
test("a backend name the plugin does not know is probed tolerantly, whatever it looks like", async (t) => {
// The protocol table is a plain object, so it inherits from
// `Object.prototype`: `constructor` and `__proto__` are not backends. A name
// the plugin has never heard of must be probed like any other unknown name,
// and must never be reported as a known backend written in the wrong case.
const directory = mkdtempSync(join(tmpdir(), "reme-llm-prototype-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const names = ["constructor", "__proto__", "CONSTRUCTOR", "__PROTO__"];
for (const name of names) {
writeFileSync(
join(directory, ".env"),
[
`LLM_BACKEND=${name}`,
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
"LLM_BASE_URL=https://provider.invalid",
].join("\n"),
);
const calls = [];
const result = await testLlm(
{
run: async (exe, args, options) => {
calls.push({ exe, args, options });
return {
code: 0,
stdout:
'{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
},
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
);
assert.doesNotMatch(
result.error ?? "",
/lower case/,
`${name} is not reported as a known backend in the wrong case`,
);
assert.equal(calls.length, 1, `${name} is probed tolerantly`);
const snippet = String(calls[0]?.args[1] ?? "");
assert.match(
snippet,
/\/chat\/completions/,
`${name} gets the default protocol`,
);
assert.match(
snippet,
/"Authorization"/,
`${name} gets the default protocol`,
);
assert.doesNotMatch(
snippet,
/undefined/,
`${name} builds no malformed snippet`,
);
}
});
test("a .env without the backend key is probed with the default protocol", async (t) => {
// The key is absent, so ReMe applies its default; the probe does the same.
const directory = mkdtempSync(join(tmpdir(), "reme-llm-absent-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
writeFileSync(
join(directory, ".env"),
[
"LLM_MODEL_NAME=test-model",
"LLM_API_KEY=sk-test",
"LLM_BASE_URL=https://provider.invalid",
].join("\n"),
);
const calls = [];
await testLlm(
{
run: async (exe, args, options) => {
calls.push({ exe, args, options });
return {
code: 0,
stdout: '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n',
stderr: "",
};
},
},
{ workspaceDir: directory, pythonPath: "C:\\env\\python.exe" },
);
const snippet = String(calls[0]?.args[1] ?? "");
assert.match(snippet, /\/chat\/completions/);
assert.match(snippet, /"Authorization"/);
assert.match(snippet, /Bearer /);
});

View file

@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SETTINGS_SPECS,
timeFromCron,
validTimezone,
} from "../dist/client/settings-specs.js";
const spec = (field) => {
const found = SETTINGS_SPECS.find((item) => item.field === field);
assert.ok(found, `missing spec for ${field}`);
return found;
};
/** The write a draft stages, or the string "invalid" when it is refused. */
const parse = (field, text) => {
const write = spec(field).parse(text);
return write === undefined ? "invalid" : write;
};
test("the one spec table defines every setting exactly once", () => {
// One table is the only definition: the exported field list and the
// `SettingField` union are derived from it, so there is no second list to
// drift. What remains worth asserting is that the table itself is sound.
const fields = SETTINGS_SPECS.map((item) => item.field);
assert.equal(
new Set(fields).size,
fields.length,
"a setting is defined twice",
);
assert.equal(fields.length, 15);
});
test("numbers accept a bounded whole number and clear on empty", () => {
assert.deepEqual(parse("searchLimit", "8"), { kind: "set", value: 8 });
assert.deepEqual(parse("searchLimit", " 12 "), { kind: "set", value: 12 });
assert.deepEqual(parse("searchLimit", ""), { kind: "clear" });
// Out of range, not whole, or not a number blocks the save.
assert.equal(parse("searchLimit", "0"), "invalid");
assert.equal(parse("searchLimit", "51"), "invalid");
assert.equal(parse("searchLimit", "8.5"), "invalid");
assert.equal(parse("searchLimit", "eight"), "invalid");
assert.equal(parse("shutdownTimeoutMs", "99"), "invalid");
assert.equal(parse("requestTimeoutMs", "120001"), "invalid");
});
test("booleans round-trip through their control text", () => {
assert.deepEqual(parse("autoMemoryEnabled", "true"), {
kind: "set",
value: true,
});
assert.deepEqual(parse("rootAgentsOnly", "false"), {
kind: "set",
value: false,
});
assert.equal(spec("autoDreamEnabled").format(true), "true");
assert.equal(spec("autoDreamEnabled").format(false), "false");
});
test("the endpoint keeps its URL shape and rejects credentials", () => {
assert.deepEqual(parse("endpoint", " http://127.0.0.1:2333/ "), {
kind: "set",
value: "http://127.0.0.1:2333",
});
for (const bad of [
"not-a-url",
"http://alice:SECRET@localhost:2333",
"http://localhost:2333?token=SECRET",
"http://localhost:2333#search",
" ",
])
assert.equal(parse("endpoint", bad), "invalid", bad);
});
test("the daily time control writes a cron expression", () => {
assert.deepEqual(parse("dreamCron", "09:30"), {
kind: "set",
value: "30 9 * * *",
});
assert.deepEqual(parse("dreamCron", "0:05"), {
kind: "set",
value: "5 0 * * *",
});
assert.equal(spec("dreamCron").format("30 9 * * *"), "09:30");
assert.equal(spec("dreamCron").format("*/5 * * * *"), "");
assert.equal(parse("dreamCron", "24:00"), "invalid");
assert.equal(parse("dreamCron", "09:60"), "invalid");
assert.equal(parse("dreamCron", ""), "invalid");
assert.equal(timeFromCron("5 0 * * *"), "00:05");
});
test("language and timezone only accept what the Host accepts", () => {
assert.deepEqual(parse("language", "en"), { kind: "set", value: "en" });
assert.deepEqual(parse("language", "zh"), { kind: "set", value: "zh" });
assert.equal(parse("language", "fr"), "invalid");
assert.equal(spec("language").format(undefined), "zh");
assert.deepEqual(parse("timezone", " Asia/Shanghai "), {
kind: "set",
value: "Asia/Shanghai",
});
assert.equal(parse("timezone", "Mars/Olympus"), "invalid");
assert.equal(parse("timezone", ""), "invalid");
assert.equal(validTimezone("Europe/Berlin"), true);
});
test("the guidance text is taken verbatim", () => {
assert.deepEqual(parse("dreamHint", "summarise the week"), {
kind: "set",
value: "summarise the week",
});
assert.deepEqual(parse("dreamHint", ""), { kind: "set", value: "" });
});

View file

@ -325,10 +325,9 @@ class AutoFinStep(BaseStep):
if tool_context_id:
kwargs["tool_context_id"] = tool_context_id
result = await self.agent_wrapper.reply(prompt, **kwargs)
if not isinstance(result, dict) or result.get("structured_output") is None:
raise ValueError(f"Auto Fin Agent returned no structured output: {self._preview(result)}")
value = result["structured_output"]
output = value if isinstance(value, model) else model.model_validate(value)
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
output = model.model_validate(result["structured_output"])
rendered = self._preview(output.model_dump(), AGENT_OUTPUT_LOG_LIMIT)
self.logger.info(
f"[{self.name}] agent output prompt={prompt_name} schema={model.__name__} "

View file

@ -7,6 +7,8 @@ import json
import re
from time import perf_counter
from reme.steps.evolve._evolve import agent_reply_result_text
from .base import AGENT_INPUT_LOG_LIMIT, AGENT_OUTPUT_LOG_LIMIT, AutoFinStep
@ -43,7 +45,7 @@ class AutoFinTopicStep(AutoFinStep):
started_at = perf_counter()
result = await self.agent_wrapper.reply(prompt)
try:
ids = self._parse_news_ids(result.get("result") if isinstance(result, dict) else None, topics)
ids = self._parse_news_ids(agent_reply_result_text(result), topics)
except ValueError as exc:
if attempt:
raise ValueError(f"Auto Fin topic Agent returned invalid news IDs: {exc}") from exc

View file

@ -149,7 +149,10 @@ class _TopicAgent(BaseAgentWrapper):
async def reply(self, inputs, **kwargs):
self.calls.append((str(inputs), kwargs))
return {"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。"}
return {
"status": "completed",
"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。",
}
@pytest.mark.asyncio
@ -221,7 +224,10 @@ async def test_topic_step_retries_invalid_json_once(tmp_path: Path):
async def reply(self, inputs, **kwargs):
self.calls.append((str(inputs), kwargs))
return {"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```')}
return {
"status": "completed",
"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```'),
}
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
agent = RetryAgent({"黄金": []}, app_context=app_context)
@ -293,6 +299,7 @@ class _ReportAgent(BaseAgentWrapper):
topic = re.search(r"当前主题:(\S+)", prompt)
title = f"{topic.group(1)}观察" if topic else "主题新闻观察"
return {
"status": "completed",
"structured_output": AutoFinReportOutput(
title=f"# {title}",
description="关注政策变化。",
@ -363,6 +370,7 @@ async def test_research_names_the_note_after_the_topic_not_the_agent_title(tmp_p
class _VerboseAgent(BaseAgentWrapper):
async def reply(self, _prompt, **_kwargs):
return {
"status": "completed",
"structured_output": AutoFinReportOutput(
title="机器人主题 9-18 研究:" + "量产与政策" * 60,
description="关注政策变化。",

View file

@ -25,6 +25,7 @@ import numpy as np
from json_repair import repair_json
from reme.steps.base_step import BaseStep, Ref
from reme.steps.evolve._evolve import agent_reply_result_text
from reme.components.as_embedding import BaseAsEmbedding
from reme.enumeration import ComponentEnum
@ -77,7 +78,7 @@ async def _llm_equivalence(agent_wrapper, reference: str, system: str) -> bool:
user_prompt = f"First snippet: {reference}\n\nSecond snippet: {system}"
result = await agent_wrapper.reply(user_prompt, system_prompt=system_prompt)
raw = (result.get("result") or "").strip().lower()
raw = agent_reply_result_text(result).lower()
return "yes" in raw
@ -277,7 +278,7 @@ class BeamRubricJudgeStep(BaseStep):
prompt = judge_template.replace("<rubric_item>", item).replace("<llm_response>", llm_response)
result = await self.agent_wrapper.reply(prompt)
raw = (result.get("result") or "").strip()
raw = agent_reply_result_text(result)
try:
parsed = _parse_json_response(raw)

View file

@ -2,7 +2,7 @@
import os
from reme.enumeration import ChunkEnum
from reme.schema import collect_reply
from reme.steps.base_step import BaseStep
from reme.steps.index._dedup import _ToolContextDedupMixin
from reme.utils.counter import global_counter_inc
@ -61,16 +61,24 @@ class BeamAgenticAnswerStep(BaseStep):
if injected_job_kwargs := self._injected_job_kwargs(f"{query}(query time: {query_time})"):
wrapper_kwargs["injected_job_kwargs"] = injected_job_kwargs
if self.context.stream:
text = await self._stream_reply(query, **wrapper_kwargs)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
text = (result.get("result") or "").strip()
try:
if self.context.stream:
result = await collect_reply(
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
on_chunk=self.context.add_stream_chunk,
)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
finally:
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
text = result["result"].strip()
self.context.response.metadata.update(result)
self.logger.debug(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
self.context.response.answer = result["error"] or text
self.context.response.metadata.update(
{
"query": query,
@ -79,23 +87,4 @@ class BeamAgenticAnswerStep(BaseStep):
"response": text,
},
)
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
return self.context.response
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
"""Stream unified chunks to the context stream queue."""
assert self.context is not None
text_parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
await self.context.add_stream_string(chunk.chunk, chunk.chunk_type)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
text_parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
return "".join(text_parts).strip()

View file

@ -30,8 +30,9 @@ _OutputT = TypeVar("_OutputT", bound=BaseModel)
def structured_output(result: dict[str, Any], model: type[_OutputT]) -> _OutputT:
"""Validate an agent wrapper's structured output."""
value = result.get("structured_output")
return value if isinstance(value, model) else model.model_validate(value)
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
return model.model_validate(result["structured_output"])
def strip_frontmatter(body: str) -> str:

View file

@ -82,7 +82,12 @@ class _QueuedAgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **kwargs) -> dict:
"""Record the request and pop the next structured fixture."""
self.calls.append({"inputs": inputs, "kwargs": kwargs})
return {"structured_output": self.outputs.pop(0), "result": "ok"}
return self._reply_result(
session_id="session",
result="ok",
raw={},
structured_output=self.outputs.pop(0),
)
def _paper(arxiv_id: str, *, title: str = "Paper", upvotes: int = 10) -> PaperInfo:

View file

@ -9,6 +9,7 @@ from urllib.parse import quote_plus
from reme.components.agent_wrapper import handle_session_command
from reme.steps.base_step import BaseStep
from reme.steps.evolve._evolve import agent_reply_result_text
def _session_key(message: Any) -> str:
@ -151,17 +152,11 @@ class DingTalkWaitStep(BaseStep):
builtin_tools=self.builtin_tools,
job_tools=self.job_tools,
)
if not isinstance(result, dict):
raise TypeError("Agent reply must be a dictionary")
if session_id := result.get("session_id"):
if session_id := result["session_id"]:
sessions[key] = session_id
last_message = result.get("last_message")
if isinstance(last_message, dict) and last_message.get("is_error"):
raise RuntimeError("Agent 执行失败")
answer = result.get("result")
if not isinstance(answer, str) or not (answer := answer.strip()):
answer = agent_reply_result_text(result)
if not answer:
raise ValueError("Agent 返回了空回复")
response = await asyncio.to_thread(handler.reply_markdown, "ReMe Agent", answer, message)
if response is None:

View file

@ -155,7 +155,8 @@ class _AgentWrapper(BaseAgentWrapper):
self.reply_calls = []
self.compact_calls = []
self.result_text = "回答"
self.is_error = False
self.status = "completed"
self.error = None
async def compact_session(self, session_id):
self.compact_calls.append(session_id)
@ -163,11 +164,13 @@ class _AgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **kwargs):
self.reply_calls.append((inputs, kwargs))
session_id = kwargs.get("resume") or "session-1"
return {
"session_id": session_id,
"last_message": {"is_error": self.is_error},
"result": self.result_text,
}
return self._reply_result(
session_id=session_id,
result=self.result_text,
raw={},
status=self.status,
error=self.error,
)
class _Handler:
@ -403,3 +406,23 @@ async def test_stream_client_reconnects_after_server_request(monkeypatch, tmp_pa
step.logger.info.assert_called_once_with(
"[DingTalkWaitStep] DingTalk server requested reconnect reason='connection is expired'; reconnecting in 1.0s",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
@pytest.mark.parametrize("error", [None, "provider failed"])
async def test_incomplete_reply_is_not_sent_as_a_success(tmp_path, status, error):
"""Partial text cannot turn a failed/interrupted/waiting AgentReply into a Markdown reply."""
app_context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _AgentWrapper(app_context=app_context)
wrapper.status, wrapper.error = status, error
wrapper.result_text = "partial answer"
step = DingTalkWaitStep(app_context=app_context, agent_wrapper=wrapper)
step.logger = MagicMock()
handler, sessions, message = _Handler(), {}, _message()
key = _session_key(message)
with pytest.raises(RuntimeError, match=error or status):
await step._handle_message(message, key, sessions, handler)
assert not handler.markdown_replies
assert sessions[key] == "session-1"
assert not any("completed DingTalk reply" in call.args[0] for call in step.logger.info.call_args_list)

View file

@ -1,16 +1,78 @@
# Home plugin
Provides the `auto_bash` job through an independently enabled ReMe plugin.
Adds a self-evolution workspace, the one-time `home_prepare` job, `home_evaluate`, and
`auto_bash` through an independently enabled ReMe plugin.
Source code is grouped by workflow under `src/reme_home/preparation/` and
`src/reme_home/evaluation/`. Preparation contains its component and Step, config,
code export, transaction recovery, and benchmark data adapters. Evaluation contains
its component, Step, input validation, Docker request adapters, and artifacts with
failure classification. Shared workspace and host judge components live at the
package root.
Install it into the same Python environment as ReMe:
```bash
reme plugins install plugins/home
reme plugins install plugins/beam-judge # Use plugins/lme-judge for LongMemEval.
```
Enable it in an application config with `plugins: [home]`. The plugin then
contributes the `auto_bash` job. Start `reme start config=<your-config.yaml>`
in one terminal, then call `reme auto_bash command=pwd` from another.
Enable it with the matching judge, e.g. `plugins: [home, beam-judge]`, then start the
service and call its jobs from another terminal:
```bash
reme start config=<your-config.yaml>
# In another terminal:
reme auto_bash command=pwd
```
At application startup, Home automatically adds these directories inside the
configured ReMe `workspace_dir/home/`:
```text
code/repo/
code/runtime/
datasets/training-set/cases/
datasets/debug-cases/cases/
docs/
evaluations/
sampling/plans/
tmp/
logs/
metadata/
```
ReMe's existing memory, resource, and session directories remain in place.
On first initialization, each managed directory under `home/` must be absent or
completely empty (an empty child directory still counts as content). File
conflicts, symbolic links in managed paths, and overlap with configured ReMe
directories cause startup to fail without overwriting their contents.
Home atomically publishes `home/metadata/workspace.json` after creating
the layout. It records layout schema version 1, a workspace UUID, and an aware
creation timestamp. Subsequent starts validate this marker, preserve its identity
and existing contents, and recreate missing directories. Recreating directories
does not recover missing data. Invalid or unsupported markers fail explicitly.
A process lock at `home/metadata/workspace.lock` prevents concurrent
initialization; the lock file remains on disk, but process exit releases the
lock. A competing initializer can retry after the first finishes.
Failed initialization removes only newly created empty layout directories and
temporary manifest files. Existing directories and files are preserved. After
an abrupt process termination before the marker is published, inspect any
nonempty Home directories before retrying; they are not automatically adopted.
Older flat Home workspaces are rejected without moving or deleting files; use a new
workspace for this layout. Relative input paths and `spec_path` still resolve against
the outer ReMe workspace, not `home/`.
Initialization creates the layout only: it does not install datasets, copy
candidate code or documentation, initialize Git, or generate experiment/domain
configuration, sampling state, or evaluation results.
For explicitly one-shot CLI execution, use:
```bash
reme start config=<your-config.yaml> job=auto_bash command=pwd
```
The job always uses a file sandbox (Bubblewrap on Linux, Seatbelt on macOS),
with `workspace_dir` read-only by default, network access blocked, and a minimal
@ -40,3 +102,856 @@ trusted callers may inject a different backend.
Standard output and standard error each have a 1 MiB (1,048,576-byte) limit on
all backends. Exceeding either limit terminates the command and returns a
failure with bounded partial output and a diagnostic in standard error.
## Prepare an experiment once
`home_prepare` runs explicitly after startup. It converts BEAM or LongMemEval
inputs, exports a committed ReMe runtime and selected plugins, and initializes a
candidate Git repository on branch `init`. It does not install dependencies, run
source code, build Docker images, call models, or evaluate the baseline.
Copy [examples/prepare.yaml](examples/prepare.yaml) into your workspace as
`prepare.yaml` and edit the source paths, revision and case IDs. For LongMemEval,
set `benchmark.name: longmemeval`, point `source` at its JSON file, remove
`variant`, and choose `runtime.plugins: [lme]` and `candidate.paths:
[plugins/lme/src]`. Optional `benchmark.evidence_source` accepts reviewed JSONL
session evidence. Choose nonempty, unique string `case_ids`, or explicitly set
`all_cases: true` without `case_ids`. Relative input paths are workspace-relative;
explicit absolute dataset and repository paths are read-only source inputs.
`spec_path` must resolve to a file inside the workspace. Home-relative paths and
relative traversal are rejected.
Start a service configured with `plugins: [home]` and your `workspace_dir`, then
call it from another terminal using the same service selection as other jobs:
```bash
reme start config=<your-config.yaml>
# In another terminal:
reme home_prepare spec_path=prepare.yaml
```
For explicitly one-shot local execution:
```bash
reme start config=<your-config.yaml> job=home_prepare spec_path=prepare.yaml
```
Python uses the same Job and normal Application lifecycle; see
[examples/prepare.py](examples/prepare.py). Pass exactly one of `spec_path` or
`spec` (a configuration dictionary):
```python
from reme.application import Application
app = Application(workspace_dir="/experiments/run-1", plugins=["home", "beam-judge"], service={"backend": "cli"})
try:
await app.start()
response = await app.run_job("home_prepare", spec=preparation_config)
if not response.success:
raise RuntimeError(response.answer)
finally:
await app.close()
```
All prepared outputs live under `workspace_dir/home/` (paths below are relative to `home/`):
- `domain_spec.yaml`: resolved configuration, including actual selected case IDs
and the full source commit SHA.
- `datasets/training-set/`: normalized Case/Session/Query JSON and a
manifest with case IDs and content fingerprints.
- `datasets/debug-cases/`: deterministic small smoke cases with source case IDs
in their metadata, or an empty `cases/` directory when debug derivation is disabled.
- `code/runtime/` and `code/repo/`: fixed committed runtime and candidate Git
repository. See the [workspace and modification boundary](src/reme_home/runtime_docs/architecture-guide.md#workspace-and-modification-boundary)
for their roles and editable scope. Source exports exclude uncommitted edits;
`install_projects` paths are relative to the assembled evaluation tree.
- `docs/architecture-guide.md` and `docs/validation-artifact-guide.md`: guides
shipped with the installed Home plugin, covering candidate development and
investigation of evaluation artifacts.
- `metadata/preparation.json`: completion record, config/data/code
fingerprints, initial candidate commit, plugin and installation plan.
Before preparation, code and docs directories must be empty, dataset directories may
contain only their empty `cases/` skeleton, and `domain_spec.yaml` must be absent.
Candidate paths must be present in the selected source, cannot overlap, and may
select ReMe core or plugin files. Runtime plugins are selected explicitly; the host plugin list is not copied. All selected source files must be regular Git blobs; symlinks and
submodules are rejected. Source references are explicit Git revisions; this
version does not support uncommitted working-tree snapshots or remote fetching.
Preparation holds the same process lock as layout initialization. Inputs are
staged beneath `home/metadata/preparation-transaction/`, flushed, and
published with a durable rollback journal. Reported preparation failures restore
pre-existing files and directories (filesystem timestamps are not part of
the contract); the corrected request can be retried. Interrupted transactions
are recovered at startup or before another preparation. A completed preparation
rejects **every** later call, including identical parameters. There is no reset or
force-overwrite option: use a new workspace for another experiment.
## Evaluate prepared candidates
`home_evaluate` runs committed candidate code through
[the shared Docker pool](../../deploy/benchmark_docker/README.md), then scores
answers through the host Application's `answer_judge` Job. Enable `beam-judge`
for BEAM or `lme-judge` for LongMemEval. Home requires exactly one supported judge
plugin; enabling additional benchmark judge plugins is an error. On a prepared
workspace, startup checks that the judge matches the recorded benchmark. On a
new workspace, `home_prepare` checks the match before preparing data or code.
A disabled or replaced `answer_judge` Job is rejected.
Install ReMe's `benchmark-docker` extra and build the documented base image first.
The host and containers need the configured LLM/embedding environment; the host
judge additionally uses `JUDGE_MODEL_NAME`. Install the corresponding judge plugin
on the host. Its code and prompts come from the host installation, never the
candidate repo; runs record a fingerprint of the judge code and configuration. Credentials are never written into
the result manifest. No initial evaluation is triggered by `home_prepare`.
Start a service with Home enabled, then call from another terminal:
```bash
reme start config=<your-config.yaml>
# In another terminal: all prepared training cases, all their queries
reme home_evaluate
```
Programs inject selections directly, without putting case IDs in the Job YAML:
```python
# app is already started with plugins=["home", "beam-judge"].
result = await app.run_job("home_evaluate", case_ids=["1", "2"], concurrency=4)
# Recover a prior pre-query memory snapshot, skip construction, answer and score again.
replayed = await app.run_job(
"home_evaluate",
case_ids=["1"],
reuse_construction_from="evaluations/init/<commit-prefix>/<run-id>",
)
```
Omitted/null `case_ids` selects the entire installed dataset, including when a
reuse source is supplied. Empty, duplicate and unknown selections fail before
Docker starts. Optional `query_ids` maps case IDs to nonempty query-ID lists;
omitted entries run all queries for that case. Evaluation always uses the current
HEAD of `home/code/repo` and records its commit in the results. Run the baseline
before modifying the prepared candidate. Uncommitted modifications, staged changes
and untracked files reject evaluation before Docker starts, as do changed fixed
runtimes/datasets.
The source snapshot combines fixed ReMe with complete plugins from the selected
candidate commit; candidate core roots replace their baseline roots, including
deleted files. Source snapshots exclude `.git` and credentials.
`reuse_construction_from` must identify a completed training-set run inside this
Home's `evaluations` directory, either absolutely or relative to `home/`. There
is no `eval_only` toggle. Every selected case must have a successful construction
and a compatible, checksum-verified tar; any missing/invalid case rejects the
entire request without silently skipping or rebuilding it. Query-only replay
uses current candidate code over the donor's memory, not donor answers/scores.
For explicit `reuse_construction_from`, the caller must establish construction
equivalence. Automatic sampled/budgeted selection instead invokes the host's
equivalence reviewer and 60% replay policy. Runs record both commits.
Results use `home/evaluations/<branch>/<commit-prefix>/<ascending-run-id>/`.
The [artifact guide](src/reme_home/runtime_docs/validation-artifact-guide.md#saved-files)
is the reference for saved files, authoritative query records, compaction and
failure evidence. Its [summary reference](src/reme_home/runtime_docs/validation-artifact-guide.md#summary-fields)
covers score denominators, token accounting, debug fields and summary rebuilding.
Before comparing runs, verify completed scored execution, infrastructure outcomes,
candidate code and complete case/query coverage.
The Job returns `success=false` for infrastructure/judge/artifact failures while
preserving its structured answer and result directory; candidate failures remain
ordinary evaluation observations. Successful Jobs returning empty answers count
as candidate failures. Construction failure-rate tolerance applies only to
candidate errors; infrastructure or unclassified failures always reject construction.
See [failure interpretation](src/reme_home/runtime_docs/validation-artifact-guide.md#investigating-a-result)
for structured error classification.
Run/case/query `failure.json` files preserve diagnostics. Interrupted/fail-fast
runs publish `partial_summary.json`, not a completed summary. New executions
always allocate a new run; they never overwrite previous results. For scored runs, default
`fail_fast=false` keeps independent cases running. The shared pool executes each
case's build followed by queries, rather than Meta-ReMe's global construction
barrier/query-stealing schedule. `max_construction_failure_rate` defaults to the
old strict-below-3% rule; other pool callers retain strict build-success behavior.
`debug=true` selects prepared debug cases and runs smoke inputs without a judge.
It defaults to `fail_fast=true`; scored runs (`debug=false`, the default) use
`fail_fast=false`. An explicitly supplied `fail_fast` overrides either default.
Debug requires successful construction and nonempty, error-free answers, and
returns `success=false` for candidate failures as well as infrastructure failures.
It cannot reuse construction. Case/query selections refer to the selected mode's
dataset; omitted selections run all its cases/queries.
Debug results retain execution counts and artifacts, with null mean scores.
See the [summary reference](src/reme_home/runtime_docs/validation-artifact-guide.md#summary-fields)
for coverage fields and manifest mode.
The former `dataset_kind` input is rejected; use `debug` instead. Debug results
must not be used to assess answer correctness or candidate quality. No real
Docker/model evaluation is required by Home's isolated unit tests.
Only Home's writers cooperate with this lock. Keep the preparation target
locations exclusively owned by this task while preparing and recovering; other
processes must not write there. Recovery requires a writable, functioning
filesystem; if rollback fails, its journal remains for the next attempt and the
Job reports the error. Calls with `_allowed_paths` restrictions are rejected
because preparation owns multiple workspace output locations.
## Bounded agent foundation
`reme_home.agent.BoundedAgentWrapper` inherits ReMe's `AsAgentWrapper` and is
registered as the `home_bounded` agent-wrapper backend when Home is enabled.
It reuses ReMe's Job tools, hidden `injected_job_kwargs`, `tool_context_id`,
built-in tools, permissions, model configuration, skills, token accounting and
persisted sessions. Home configures a wrapper for the single-case investigation
Job below; no investigation or optimization runs automatically. Configure other
agents explicitly, alongside the required model and Home plugins:
```yaml
components:
agent_wrapper:
investigator:
backend: home_bounded
as_llm: default
react_config:
max_iters: 40
time_budget_minutes: 30 # Optional advisory budget; null disables it.
job_tools: [home_evaluate]
injected_job_kwargs:
case_ids: ["1"] # Hidden from the model and enforced by the host.
```
Call `reply()` or `reply_stream()` through the normal ReMe wrapper interface;
call-time options override component defaults. Each built agent owns its own
timer and request state. The internal `BoundedReActAgent` still subclasses the AS
runtime to implement loop hooks; callers use `BoundedAgentWrapper` for ReMe wiring.
The lower-level `BoundedReActAgent` and `RunTimeBudgetTimer` exports remain available.
Each reply invocation starts and stops its advisory timer, including on errors and
cancellation. Iteration reminders occur at 50%, 75%, 90% and 95%; coincident or
skipped thresholds produce one reminder. Time reminders occur at 60%, 75%, 90%,
100%, then each additional 10%, alternating temporary and retained messages. Time
budgets do not cancel model calls or tools. Completed context replacements are
counted in time reminders. Reminders use native AS hint blocks, preserving the
reply's cumulative token usage across reasoning and tool calls. Use separate
agent instances for concurrent work.
The AgentScope loop owns the iteration cap and its single finalization call.
For text reports, Home removes all tools from that call, retains the original
task, and trims oldest messages if needed to fit the context budget. The final
report is still attempted if its preceding context compression fails; ordinary
compression errors outside finalization and cancellation continue to propagate.
The final response is buffered until complete, including for streaming models; unsolicited
tool calls are discarded. If finalization fails or returns no text, the reply
explicitly identifies any retained prior model output as incomplete. Cancellation
and provider interruption keep AgentScope's interruption behavior. Native
structured-output requests retain the SDK's schema and grace-iteration behavior.
Events and final-message token usage retain AgentScope's existing semantics.
## Candidate versions and code inspection
Home registers the `home.code` Component and ten ordinary Step-backed Jobs. They
operate only on the prepared `home/code/repo`, sharing the preparation/evaluation
process lock. Enable Home and prepare the workspace before calling them.
Branch names must be valid local Git branch names of 1–100 characters and serve as
single evaluation-directory names, so `/` and `\` are forbidden.
| Job | Inputs and behavior |
| --- | --- |
| `home_code_status` | Current branch, full commit SHA, exact changed paths and pending merge state. |
| `home_list_candidate_branches` | Local branches and candidates, excluding protected `init` from the candidate count. |
| `home_create_candidate_branch` | `branch_name`: fork current HEAD and switch to a new candidate. |
| `home_switch_candidate_branch` | `branch_name`: switch to an existing local branch, including `init`. |
| `home_merge_candidate_branch` | `branch_name`: merge another candidate without committing; conflicts are aborted. |
| `home_commit_candidate` | `message`: stage and commit all visible changes within prepared `candidate_paths`. |
| `home_code_diff` | Omit inputs for tracked changes against HEAD; supply `base_revision` and `revision` to compare commits. With only `base_revision`, compare the working tree against that commit. |
| `home_list_code_tree` | Optional `revision`, literal `path`, and `max_results` (default 500, maximum 2000). |
| `home_read_code_file` | Required `path`; optional `revision`, one-based `start_line` and inclusive `end_line`. |
| `home_search_code` | Required Python regex `pattern`; optional `revision`, literal `path`, and `max_results` (maximum 30). |
Start the configured service in one terminal, then use a second terminal:
```bash
reme start config=/absolute/path/to/home.yaml
```
```bash
reme home_code_status
reme home_create_candidate_branch branch_name=retrieval-routing
# Edit files under the prepared candidate_paths, then inspect and commit.
reme home_code_diff
reme home_commit_candidate message='Improve evidence routing'
reme home_evaluate debug=true
reme home_evaluate
```
Branch names must be single path-safe names without slashes. Creation, switching
and merging require a clean checkout, including untracked files and pending
merges. `init` must continue to point to the preparation's initial commit; commits
and merges into it are rejected. Detached candidates, symlinks, submodules and
changes outside `candidate_paths` are rejected by the managed mutation tools.
Merges require an explicit later commit. No remote operations, branch deletion,
amend, reset or rebase are exposed. Empty ordinary commits fail through Git.
These guarantees apply to cooperating Home Jobs; do not grant an optimizer
unrestricted shell writes or direct access to `.git` if it must obey this boundary.
Cancellation waits for already-started Git work to finish before returning.
Code-navigation Jobs resolve `revision` (default `HEAD`) to a full SHA once per
call, read raw committed objects and never execute candidate code. Dirty working
files and Git archive attributes do not affect these reads. Pin the same SHA
across a diagnostic session through ReMe's hidden injection:
```yaml
job_tools: [home_list_code_tree, home_read_code_file, home_search_code]
injected_job_kwargs:
revision: "<full-commit-sha>"
```
The wrapper hides injected fields from the model and rejects attempts to supply
them. For comparisons, configure two agent invocations or two named Job aliases
with separately pinned revisions. `HomeCodeStep` takes a fixed `operation` in
Step configuration; repository location and operation are never model-selected.
The Python entry point is `await home_code.run(operation, **arguments)`.
Reads are limited to 300 lines and 100,000 content characters, with explicit
truncation flags. Search accepts Python multiline regex, merges adjacent match
contexts, and displays at most five lines per result block and 100,000 content
characters. Files above 5 MB and searches above 100 MB are rejected; regex
matching runs in a separate process with a ten-second timeout. Use narrower
paths or line ranges to continue inspecting. These tool-operation timeouts are
independent of the bounded agent's advisory time budget.
Evaluation also checks the standalone repository identity and protected baseline
before taking its existing clean-HEAD snapshot. Formal evaluation deduplication,
automatic ancestor/donor selection and optimization scheduling remain separate
policies; these Jobs do not enable an optimizer or change evaluation selection.
## Evaluation sampling and history
`home_evaluate` is the single evaluation Job. Set `sampling=true` for adaptive
case/query selection, or leave it false for a full baseline or an explicit
host-provided `case_ids`/`query_ids` selection. `sampling=true` conflicts with
explicit selections in scored mode. `debug=true` ignores the sampling flag,
uses debug cases and never reads, writes or registers evaluation history.
The former `home_validate` Job is removed; use `home_evaluate sampling=true`.
Scored evaluations reject a Git tree already evaluated anywhere in this workspace,
including another branch or an empty commit. This applies to full, sampled and
construction-replay runs, even with `record_history=false`. Changing selections or
evaluation options does not bypass the check. The error points to the previous
result; inspect it and commit a code change before evaluating again. The Job returns
`success=false` with an answer containing `status="rejected"`,
`error_code="UNCHANGED_CANDIDATE"`, `error` and the previous `result_dir`.
`metadata.result_dir` also points to that result. Debug runs neither trigger nor
participate in this check.
An infrastructure failure or interrupted attempt can retry the exact same commit
with a new run ID; the previous artifacts remain intact. Recovery uses exactly the
failed attempt's case/query IDs and order, even if the caller changes sampling,
budgets or explicit selections. It reruns the selected batch rather than only
failed queries, and records an explicit selection plan with `recovery_from`
pointing to the prior run. Other execution options are not frozen; construction
reuse still follows `reuse_construction_from`. Candidate-code failures
and valid low scores require a code change. A completed summary with infrastructure
errors allows retry even when other cases succeeded; an incomplete attempt with
candidate-failure evidence and no infrastructure failure does not. An empty commit
cannot convert a recoverable attempt into a new candidate. The guard reads saved
artifacts under the formal evaluation lock and does not depend on sampling caches
or successful-history registration.
Selection and optimization-history recording are independent. `record_history`
defaults to `true` for compatibility: a first full optimization baseline contributes
observations even with `sampling=false`. Set `record_history=false` to score code
without feeding optimization history. This still saves the manifest, scores and
result artifacts, but never registers a history entry, increments either counter
or updates sampling observations. Rebuilding history preserves that exclusion.
Debug always skips history regardless of either flag.
Evaluation parameters have distinct names and lifetimes:
| Parameter | Where it belongs | Meaning |
| --- | --- | --- |
| `sampling` | Job input, default `false` | Enable adaptive selection for this invocation. |
| `record_history` | Job input, default `true` | Record started rounds and successful results for optimization. |
| `sampling_policy` | Component defaults or host Job input | Case/query limits and weighting settings. |
Home registers these Component defaults automatically; override the policy in
application configuration when needed:
```yaml
components:
home.evaluation:
default:
backend: home_evaluation
sampling_policy:
max_cases: 5
max_queries: null
temperature: 1.0
ewma_alpha: 0.3
uniform_exploration: 0.2
```
The Component policy is immutable. Each invocation validates its own policy,
merging a partial `sampling_policy` over Component defaults and persisting the
effective values with that run. Explicit null `max_queries` removes the query
budget. The previous Component configuration key `sampling` is now
`sampling_policy`; passing a policy object as the Job's boolean `sampling` fails.
Normal ReMe Job defaults also work: set `sampling: true` and optionally
`sampling_policy` directly under `jobs.home_evaluate`. Call-time values take
precedence over same-named Job defaults; a call-time policy object replaces the
Job-default policy object, then merges with Component defaults.
With the configured service running, use a second terminal. These are alternative
invocations; commit a code change between scored evaluations:
```bash
reme home_evaluate sampling=false # Full baseline.
reme home_evaluate sampling=false record_history=false # Standalone full evaluation.
reme home_evaluate sampling=true # Adaptive formal evaluation.
reme home_evaluate debug=true sampling=true # Debug only; no history feedback.
```
Host Python callers can override the policy for one invocation without changing
the next invocation's defaults:
```python
result = await app.run_job(
"home_evaluate",
sampling=True,
sampling_policy={"max_cases": 3, "max_queries": 12},
)
# After committing a code change, score specified prepared cases without
# affecting the optimizer's history.
standalone = await app.run_job(
"home_evaluate",
sampling=False,
record_history=False,
case_ids=["case-a", "case-b"], # Omit to run the full prepared dataset.
)
```
`sampling_policy` and explicit case/query IDs stay out of the default agent tool
schema. An optimizer that must obey a fixed budget should receive hidden,
enforced values through the normal wrapper injection:
```yaml
job_tools: [home_evaluate]
injected_job_kwargs:
sampling: true
record_history: true
sampling_policy:
max_cases: 3
max_queries: 12
```
The wrapper hides injected parameters and rejects model attempts to provide
those keys. Use ordinary call arguments when the host should decide the mode
per invocation; use injection when that decision must remain fixed for an agent.
Case/query selection retains dev_meta b53048e4's EWMA loss, staleness, observation
uncertainty, un-rechecked error priority, normalized adaptive/uniform mixture,
weighted drawing without replacement and fair-share query budget allocation.
Policy defaults are unchanged. Positive integer case/query limits, positive
finite temperature, alpha in `(0, 1]` and uniform exploration in `[0, 1]` are
validated. Seeds, draw weights, eligibility and exact selections are saved.
An explicit construction donor uses the existing dev_meta eligibility rules and
checksum checks. A later full rebuild considers the full training set again.
### Successful evaluation history
The `home.evaluation_history` Component discovers successful scored training
results and rebuilds sampling observations from saved evidence. Low scores remain
valid observations; infrastructure failures, debug and incomplete runs are excluded.
See [history and sampling evidence](src/reme_home/runtime_docs/validation-artifact-guide.md#evaluation-history-and-adaptive-sampling-evidence)
for eligibility, started/successful rounds, saved plans and cache recovery.
The full summary is the durable completion boundary: cancellation during later
history bookkeeping does not relabel the completed evaluation. Failed registration
discards only derived caches, which can rebuild from saved run evidence.
```python
history = app.context.components["home.evaluation_history"]["default"]
results = history.list_results() # Typed EvaluationRecord objects, chronological.
result = history.get_result(results[-1].run_key)
history.rebuild() # Rebuild sampling observations from saved artifacts.
```
These synchronous filesystem methods own the workspace lock; async host code can
run them in a worker thread. Formal runs remain serialized by
`home/metadata/validation.lock`; debug bypasses that lock. Rebuilding changes only
derived state; preserve source manifests, plans and result artifacts.
## Evaluation result inspection
Read-only inspection includes debug, failed, cancelled, running and standalone
(`record_history=false`) runs, independently of successful optimization history.
It never registers results, updates sampling, rebuilds summaries or writes artifacts.
| Job | Use |
| --- | --- |
| `home_list_validation_results` | Find runs, optionally filtered by `branch_name` and `commit_sha`. |
| `home_inspect_validation` | Inspect a `validation_path`, then narrow with `case_id` and `query_id`. |
| `home_trace_query_evidence` | Link a query to source sessions, memory checkpoints and saved evidence without a model call. |
| `home_inspect_memory_history` | Page through one case's exported memory Git checkpoints. |
| `home_compare_memory` | Compare committed memory within a run or across evaluations of the same case. |
With the configured service running, use a second terminal:
```bash
reme home_list_validation_results branch_name=candidate limit=10
reme home_inspect_validation validation_path=evaluations/candidate/abcdef0/1
reme home_inspect_validation validation_path=evaluations/candidate/abcdef0/1 case_id=case1 query_id=query1
reme home_trace_query_evidence validation_path=evaluations/candidate/abcdef0/1 case_id=case1 query_id=query1
reme home_inspect_memory_history validation_path=evaluations/candidate/abcdef0/1 case_id=case1
reme home_compare_memory validation_path=evaluations/candidate/abcdef0/1 case_id=case1 base_ref=HEAD~1
reme home_compare_memory validation_path=evaluations/candidate/abcdef0/1 target_validation_path=evaluations/candidate/1234567/1 case_id=case1
```
`validation_path` is absolute or relative to `home/`. See
[read-only evaluation inspection](src/reme_home/runtime_docs/validation-artifact-guide.md#read-only-evaluation-inspection)
for pagination, commit-prefix resolution, previews and source pointers, and
[memory checkpoint tools](src/reme_home/runtime_docs/validation-artifact-guide.md#memory-checkpoint-tools)
for evidence provenance, missing-history states, comparison references and limits.
Host callers use the same Jobs through `app.run_job()`. For case-scoped agent
tools, pin `validation_path` and `case_id` through `injected_job_kwargs`;
the wrapper hides those fields and rejects model overrides. These Jobs reject
path escapes, symlinks and restricted `_allowed_paths` contexts.
## Case-study batches
`home_case_study(evaluation_path, case_ids, focus=...)` investigates up to eight
unique case IDs in input order, sharing the evaluation and unchanged focus.
Cases run concurrently, each with fresh agent state, sandbox and timer. The
host Step setting `concurrency` defaults to 4, matching dev_meta, and must be a
positive integer; set it to 1 for sequential execution. Results preserve input
order regardless of completion order. Cancelling the batch cancels running and
queued cases and waits for their cleanup before returning. The
whole selection is validated before model calls; a case execution failure is
returned individually without cancelling later cases. It follows dev_meta's
case-study workflow: trace judging and answer failures backward through retrieval,
memory construction, source sessions and
the evaluated implementation. The text report cites evidence paths/lines or
JSON fields and distinguishes supported causes, hypotheses and missing evidence;
it does not prescribe fixes.
The `home_case_study` component uses `home_case_study_agent`, a thin subclass of
the bounded wrapper, defaulting to the dedicated `as_llm: home_case_study`
component with model `qwen3.7-max`, 60 iterations and a 30-minute advisory
budget, matching dev_meta's default budgets. It uses `LLM_BACKEND`, `LLM_API_KEY`
and `LLM_BASE_URL` without changing the evaluation judge's model. Override
`components.as_llm.home_case_study.model` to choose another model, or
`components.agent_wrapper.home_case_study.as_llm` to select another host component. ReMe owns model
startup, token accounting and saved session transcripts. The response answer is
`{"results": [...]}`; each item contains `case_id`, `success`, and either `report`
with its session ID/usage or `error`. Metadata includes `evaluation_path`, the
evaluated commit and total `usage` (null if any usage is unavailable).
Response success is false if any case fails; completed sibling reports remain
available. No case scratch directory or intermediate analysis files are created.
Returned reports include the agent's `status` and `error` in single-case metadata
or each batch result. Only `completed` succeeds; interrupted, waiting and
iteration-exhausted replies retain their report text and reported usage with `success=false`.
The host bounds each returned report using `output_token_limit` (default 2,000)
with a 10% allowance, rounded down. The agent sees only the configured budget.
Single-case response metadata and each batch result include `estimated_tokens`,
`original_estimated_tokens` and `truncated`. Saved sessions retain the full report,
and usage retains the full model cost. The host Step's optional `tokenizer_source`
selects a local tokenizer file/directory or Hugging Face source through
`reme.utils.qwen_token_tools`; its default is `Qwen/Qwen3.8-27B`. Exact counting
requires `reme-ai[qwen-tokenizer]`; if unavailable, the utility warns and uses its
UTF-8 byte estimate for counting and truncation.
`validation_path` remains an alias for `evaluation_path`. Legacy `case_id` calls
still return a text report with per-case metadata. Do not supply both path names
or both case selectors. Other inspection Jobs retain their `validation_path` argument.
The case-study toolkit and mandatory read-only OS sandbox are described in the
[artifact guide](src/reme_home/runtime_docs/validation-artifact-guide.md#single-case-diagnostic-agent).
The host pins case/evaluation selectors and code revision. Direct code reads can
see current worktree changes; use revision-pinned Jobs to establish evaluated behavior.
Home agents build their own sandboxed `python_execute` tool; they do not depend on
the benchmark configuration's local-only `python_execute` Job or require enabling
its HTTP/MCP exposure.
The agent tool accepts either `code` or an existing `file`, optional file-only
`args`, and a `timeout` in seconds (default 60, maximum 600). It uses `python -I -B`
and returns returncode/stdout/stderr/timed_out, preserving partial stdout and stderr
on timeout. Both executors reuse bounded output, timeouts, cancellation cleanup
and network isolation from the platform backend.
Linux requires Bubblewrap and, with the default 4096-MiB limit, a writable
delegated cgroup v2. macOS requires Seatbelt (`sandbox-exec`); memory limits are
not enforced there. The Job probes sandbox startup before calling the model and
never falls back to an unrestricted executor. The command whitelist parser is
not used by this agent. Batch concurrency, sandbox policy, output-report token guidance and executor
memory policy are host Step configuration, not model arguments:
```yaml
jobs:
home_case_study:
steps:
- backend: home_case_study_step
agent_wrapper: home_case_study
concurrency: 4
output_token_limit: 2000
# memory_limit_mib: 0 # Explicitly disable Linux cgroup memory limiting if needed.
```
With the configured service running, call from another terminal:
```bash
reme home_case_study evaluation_path=evaluations/candidate/abcdef0/1 'case_ids=["case1","case2"]' 'focus=Trace low-scoring queries'
```
Calls with `_allowed_paths` restrictions fail closed, as other Home artifact
Jobs do. Structured inspection tools run in the host with their existing
containment checks; only Bash/Python subprocesses run inside the OS sandbox.
## Diagnostic and optimizer agents
`home_diagnose` and `home_optimize` provide the minimal Meta-ReMe workflow using
Home's existing Jobs. Enable Home with a benchmark judge and prepare the
workspace first. Start the configured service in one terminal:
```bash
reme start config=/absolute/path/to/home.yaml
```
Call from another terminal:
```bash
reme home_diagnose 'evaluation_paths=["evaluations/init/abcdef0/1"]' 'instruction=Explain the lowest-scoring cases'
reme home_optimize 'instruction=Improve evidence retrieval while reducing query tokens'
```
Diagnosis requires explicit, unique result directories, including failed runs
with saved evidence. Each call creates a fresh diagnostic agent, delegates deep
case investigation to `home_case_study`, and returns ranked, evidence-supported
causes without prescribing fixes. An optimizer invocation automatically injects
its available caller messages and compression summary; the model cannot supply
or override that trajectory. Standalone calls record an empty trajectory.
Reports and trajectories are saved under `home/logs/diagnostic-reports/` and
`home/logs/diagnostic-trajectories/` with their evidence paths and session IDs.
Optimization reuses a completed full initial baseline covering every prepared
training case and query, with no infrastructure errors. Otherwise it runs that
baseline first, requiring clean `init` to be checked out. It never switches away
from an existing candidate to repair a missing baseline. Baseline observations
feed sampling history. Candidate execution failures retain Home's existing
failure thresholds; low scores are valid evidence. Infrastructure failures stop
baseline startup and are distinguished from candidate defects during iteration.
The optimizer diagnoses, edits declared `candidate_paths`, commits through
managed Git Jobs, runs debug until execution passes, then evaluates using the
host's adaptive sampling policy. The host automatically chooses whether to
rebuild memory or replay compatible construction snapshots, as described below.
The agent cannot change execution settings, selections, sampling mode, or
construction reuse through its evaluation tool; only `debug` is model-selected.
Normal `home_evaluate` callers retain their existing interface and replay support.
Unchanged candidates and infrastructure retries follow the existing repetition
rules. There is no architecture agent in this workflow.
Sampled or budgeted formal evaluations automatically find the nearest ancestor
commit with a completed scored training run and reusable construction snapshots.
The host checks dataset/runtime/config/model fingerprints, archive integrity,
successful source answers and the existing 5% construction-failure limit. Invalid
sources are skipped while looking for an older usable ancestor. The
`home_construction_equivalence` agent compares the exact source and candidate
commits. If construction is unchanged, the host draws once: 60% query-only replay,
40% full construction and evaluation. Changed or uncertain construction always
rebuilds. Substantive changes to `auto_memory.py` or its YAML/hyphenated variants
conservatively force rebuilding without a model call, matching dev_meta.
The optimizer cannot request query-only evaluation, choose a source, alter the
probability or bypass review. Its evaluation tool still exposes only `debug`.
Baseline and debug always rebuild; ordinary unsampled, unbudgeted calls and
explicit host `reuse_construction_from` calls retain their existing interfaces.
Replay sampling stays within reusable source cases and may select new queries
from those cases. Full rebuild sampling considers all prepared cases. An
infrastructure retry preserves the previous mode, source and frozen selection
without another review or probability draw. Both full and automatically reviewed
query-only results can support the optimizer's final selection; the initial
baseline must still be full and cover all cases/queries.
The equivalence agent receives one read-only export under
`home/tmp/construction-equivalence-*/`: both pinned source trees, their complete
diff, a copy of the fixed runtime and configuration, and only the chosen source's
pre-query construction workspaces, build logs and minimal construction metadata.
Git metadata is removed from exported workspaces. It cannot read live code,
datasets, query results, scores, other evaluations, optimizer trajectory or host
logs. Read, Glob, Grep and Bash all use the OS sandbox; there are no writable
paths, Home Job tools or network access. Reports are host-written under
`home/logs/construction-equivalence/`. Temporary exports, including partial copies,
are deleted when review finishes, fails or is cancelled. Reports retain commit and
source-evaluation provenance; `source_path` records the temporary location used
during review and is no longer available afterward. Model or malformed-report failures are
recorded and force a full rebuild; sandbox setup failure stops evaluation.
Both construction-equivalence and candidate-admission reports accept JSON wrapped
in prose or a code fence, ignore think blocks, and repair trailing commas outside
strings. If parsing or schema validation still fails, the reviewer makes one
format-only structured-output call with thinking disabled and no tools. This call
retains the system prompt, compression summary and investigation history, including
the invalid final reply. Successful corrections are saved in the session and their
token usage is included in the report. If correction fails, the existing fallback
applies: construction is rebuilt or candidate admission allows evaluation.
Reviewer shell commands receive an explicit minimal environment instead of
inheriting host model credentials or other environment values.
Cancellation propagates, and a branch/commit change during review fails before
sampling or spending a validation slot.
Evaluation manifests and responses retain `automatic_construction_reuse`,
including source/base/candidate commits, the equivalence report and, when drawn,
the probability, random draw and selected mode. Configure the reviewer through
`components.as_llm.home_construction_equivalence` (default `qwen3.8-max`, 128,000
context tokens) and
`components.agent_wrapper.home_construction_equivalence.react_config.max_iters`
(default 100). Optional
`components.home.evaluation.default.construction_equivalence_memory_limit_mib`
uses the existing OS sandbox memory-limit contract.
During the first `max_evaluations // 2` non-init formal starts, a fresh
`home_candidate_gate` agent reviews each new candidate before sampling or starting
an evaluation sandbox. It compares the candidate with its nearest previously
evaluated ancestor (falling back to `init`). It accepts runtime mechanism changes
and concrete execution, safety or contract fixes; it rejects prompt/parameter-only
tuning, cosmetic changes and dead code during this early exploration phase.
Ambiguous evidence is accepted. Baselines, debug, infrastructure retries and later
refinement bypass admission. Ordinary unbudgeted `home_evaluate` calls remain
ungated; supplying `max_evaluations` enables the same admission policy.
The reviewer receives only read-only source exports of the two exact commits and
their complete diff under `home/tmp/candidate-gate-*/`. Its Read, Glob, Grep and
Bash tools all use the OS sandbox, with no writable project paths or network.
The live candidate repository, Git metadata, fixed runtime, datasets, evaluations,
logs and optimizer trajectory are not granted. It cannot invoke Home Jobs.
Sandbox setup failure stops evaluation; model or malformed-report failures are
recorded and allow evaluation, following dev_meta's admission fallback policy.
Cancellation propagates. A candidate branch/commit change during review requires
a retry rather than evaluating unreviewed code.
`CANDIDATE_GATE_REJECTED` returns the reason and review path without drawing a
sample, allocating evaluation artifacts or spending a validation slot. Host-written
reports under `home/logs/candidate-gates/` retain both commits, decision, reason,
session and usage; accepted reviews are also linked from evaluation manifests.
Source exports, including partial copies, are deleted on completion, failure or
cancellation; the report's `source_path` records their former temporary location.
Configure the model via `components.as_llm.home_candidate_gate` and the 60-iteration
default via `components.agent_wrapper.home_candidate_gate.react_config.max_iters`.
The model defaults to `qwen3.8-max` with a 128,000-token context. Optional
`components.home.evaluation.default.candidate_gate_memory_limit_mib` uses the
existing OS sandbox memory-limit contract.
Both agents share the case-study OS sandbox implementation. All file tools,
Bash and Python use it, including file metadata and Edit/Write checks. Evidence,
fixed runtime and Git metadata are read-only. Each call receives a private
scratch directory under `home/tmp/`; only the optimizer additionally receives
writes to declared candidate roots. Network is denied. Diagnostic writes are
limited to scratch. Host Jobs alone write reports, evaluation artifacts and Git
metadata. Sandbox startup is probed before model calls and never falls back to
unrestricted execution. Declared write roots must exist and remain real paths.
Only one optimizer can run per Home workspace; cancellation releases its lock
after nested tool cleanup. Separate diagnostic calls and case studies may run
concurrently with fresh state.
The final optimizer response must identify `branch_name`, full `commit_sha`,
`evaluation_path` and `report`. The host verifies a clean checkout at that exact
branch/commit and a matching completed, scored, end-to-end evaluation without
infrastructure failures. Sampled formal evidence and host-selected,
construction-equivalent query-only evidence qualify; debug evidence does not.
Keeping the initial baseline is valid. Invalid final claims
fail while preserving changes and the raw report in
`home/logs/optimization-reports/`. The host verifies provenance rather than
ranking candidates by a single average score.
Dedicated `as_llm` and `agent_wrapper` components are named `home_optimizer` and
`home_diagnostic`; override them through ordinary application configuration.
Both default to `qwen3.8-max`, with context sizes 160,000 and 128,000, respectively.
The optimizer has 3,000 iterations and a 150-minute advisory validation-cycle timer;
configure `components.agent_wrapper.home_optimizer.validation_cycle_time_budget_minutes`
(null disables timed reminders). The previous `time_budget_minutes` setting is a
fallback when the cycle setting is absent. Diagnosis retains 80 iterations and
a 60-minute whole-invocation timer. Neither timer enforces a time limit.
The optimizer follows dev_meta b53048e4: before reasoning it checks 50%, 75%, 90%,
then 100% and each additional 10% of the cycle budget. Reminders include elapsed
time, budget percentage, completed context compressions and guidance to finish
editing, commit, pass the smoke test and begin validation. Temporary and durable
reminders alternate, beginning with temporary. The cycle includes nested diagnosis,
debug and evaluation time. Only a new counted formal evaluation returning
`success` or `completed` resets the clock, thresholds, alternation and compression
count; scores and candidate failures do not control reset. As in dev_meta, a
completed result can reset even when its case results contain infrastructure errors;
an incomplete failure, rejected call, cancellation, debug or pre-sandbox failure
cannot. The initial full baseline precedes the first optimizer cycle and is excluded
from its formal-validation numbering. A durable hint records the completed
validation number and commit prefix, expires that cycle's old timer reminders and
announces the next cycle with the remaining formal-validation count.
Persisted old reminders are retained as in dev_meta.
Hints use AgentScope's current reply message so accumulated token usage is retained.
Evaluation tools queue transitions in request state; the optimizer injects them
before the next reasoning call, after all tool results have been written. Thus a
transition never separates an API tool call from its matching result.
`components.agent_wrapper.home_optimizer.max_evaluations` defaults to 10, matching
dev_meta. This setting and the `home_evaluate` argument were renamed from
`max_validations`; update existing configuration and callers to `max_evaluations`.
The old name is rejected rather than silently falling back to the default budget.
`get_validation_budget` reports consumed and remaining slots, the search
phase and next-candidate guidance. A non-init formal run consumes one slot when
its first sandbox initializes successfully, including runs that later fail or are
cancelled. Initial baselines, debug, rejected calls and pre-start failures are free.
Accounting reuses persistent started-run evidence and survives optimizer restarts;
removing the rebuildable sampling cache does not restore the budget. The evaluator
checks the limit while holding the formal-validation lock, before starting another
run. At exhaustion it returns `VALIDATION_BUDGET_EXHAUSTED`; the optimizer selects
from existing evidence. The first `max_evaluations // 2` starts favor structural
exploration; later starts favor evidence-driven refinement. This guidance is advisory.
Ordinary `home_evaluate` calls remain unbounded unless the host supplies
`max_evaluations`; all recorded non-init formal starts in that workspace count.
Sessions and model usage follow ReMe's wrapper conventions; each nested call reports its own usage.
The optimizer follows dev_meta's compression recovery policy. Recoverable model
or summary-output failures leave context intact below the emergency ratio
`(trigger_ratio + 1) / 2`. At or above that ratio (90% for an 80% trigger), old
messages are atomically saved under `logs/context-offloads/`; recent context and
the existing summary are retained, with a reminder pointing to the readable JSON
file. This fallback makes no additional model call and counts as one completed
compression. Cancellation, invalid configuration and uncompressible system prompts
still propagate during normal operation. Diagnostic and case-study agents retain
their existing compression behavior.
Optimizer and diagnostic agents have `add_draft` and `read_draft` notebook tools.
Each keeps at most six reusable lessons in FIFO order, with a suggested 200 tokens
per lesson and host truncation at 220 tokens. An optimizer invocation owns its own
notebook and a separate diagnostic notebook shared by its successive fresh diagnoses.
Standalone diagnoses start with an empty notebook. These in-memory lessons survive
context compression, but are discarded when that optimization or diagnosis ends;
they are not durable workspace memory and are not shared with case-study agents.
Host-only execution settings belong in the optimizer Step; model, iteration and
timer settings belong in its wrapper. For example:
```yaml
jobs:
home_optimize:
steps:
- backend: home_optimize_step
agent_wrapper: home_optimizer
evaluation_options:
concurrency: 4
timeout_seconds: 7200
max_construction_failure_rate: 0.03
# memory_limit_mib: 0
components:
agent_wrapper:
home_optimizer:
react_config:
max_iters: 3000
validation_cycle_time_budget_minutes: 150
max_evaluations: 10
```
`evaluation_options` also accepts `base_image`, `fail_fast` and
`max_artifact_bytes`. Omit `fail_fast` to retain Home's debug/scored defaults.
Restricted `_allowed_paths` calls fail closed. No real model or Docker benchmark
is required by the isolated workflow tests.

View file

@ -0,0 +1,28 @@
"""Start a local Home application and prepare once: python prepare.py WORKSPACE."""
import asyncio
from pathlib import Path
import sys
from reme.application import Application
async def main(workspace: str):
"""Read WORKSPACE/prepare.yaml; a dictionary can instead be passed as spec=."""
app = Application(
workspace_dir=str(Path(workspace).resolve()),
plugins=["home"],
service={"backend": "cli"},
)
try:
await app.start()
response = await app.run_job("home_prepare", spec_path="prepare.yaml")
if not response.success:
raise RuntimeError(response.answer)
print(response.answer)
finally:
await app.close()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1]))

View file

@ -0,0 +1,17 @@
benchmark:
name: beam
variant: 100K
source: /data/BEAM
case_ids: ["1", "3", "8"]
# For an explicit full selection, remove case_ids and set all_cases: true.
runtime:
source_repo: /projects/ReMe
source_ref: HEAD # Resolved once to a full commit SHA; uncommitted edits are excluded.
config: benchmark.yaml
plugins: [beam]
candidate:
paths: [plugins/beam/src]
debug:
enabled: true
sessions_per_case: 3
messages_per_session: 6

View file

@ -1,7 +1,7 @@
[project]
name = "reme-home"
version = "0.1.0"
description = "Local command execution jobs for ReMe."
description = "Self-evolution workspace preparation and local command execution jobs for ReMe."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
@ -17,7 +17,7 @@ where = ["src"]
include = ["reme_home*"]
[tool.setuptools.package-data]
reme_home = ["plugin.yaml"]
reme_home = ["plugin.yaml", "runtime_docs/*.md", "agent/*.yaml"]
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"

Some files were not shown because too many files have changed in this diff Show more