Compare commits

..

No commits in common. "main" and "v0.4.1.8" have entirely different histories.

58 changed files with 778 additions and 804 deletions

View file

@ -26,14 +26,12 @@ jobs:
working-directory: github-pages
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: actions/setup-node@v6
with:
node-version: '22.22.3'
node-version: '22.13'
cache: npm
cache-dependency-path: github-pages/package-lock.json
@ -49,10 +47,10 @@ jobs:
- name: Configure Pages
if: inputs.upload_pages_artifact
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
uses: actions/configure-pages@v6
- name: Upload Pages artifact
if: inputs.upload_pages_artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
uses: actions/upload-pages-artifact@v4
with:
path: github-pages/dist

View file

@ -23,12 +23,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: '3.11'
@ -79,9 +77,25 @@ jobs:
assert (static_dir() / "index.html").is_file()
PY
# qwenpaw composes independently released plugins. Keep this before the
# artifact upload so a core release cannot advertise an unavailable extra.
- name: Verify released qwenpaw dependencies
if: inputs.expected_version != ''
run: |
REME_WHEEL="$(pwd)/$(ls dist/reme/reme_ai-[0-9]*.whl)"
python -m venv "${RUNNER_TEMP}/reme-qwenpaw-package-smoke"
"${RUNNER_TEMP}/reme-qwenpaw-package-smoke/bin/python" -m pip install "${REME_WHEEL}[qwenpaw]"
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-qwenpaw-package-smoke/bin/python" - <<'PY'
from importlib.metadata import distribution
assert distribution("reme-auto-fin")
assert distribution("reme-daily-paper")
PY
- name: Upload ReMe distributions
if: inputs.upload_artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: reme-distributions
path: dist/reme/

View file

@ -16,6 +16,7 @@ on:
- 'typescript/README*.md'
- 'plugins/*/README*.md'
- 'benchmark/*/README*.md'
- 'skills/reme_memory/SKILL.md'
pull_request:
branches: [main, master, dev, develop]
paths:
@ -31,6 +32,7 @@ on:
- 'typescript/README*.md'
- 'plugins/*/README*.md'
- 'benchmark/*/README*.md'
- 'skills/reme_memory/SKILL.md'
workflow_dispatch:
concurrency:

View file

@ -8,10 +8,9 @@ on:
- '.github/workflows/_build-python-packages.yml'
- '.github/workflows/release-python.yml'
- 'pyproject.toml'
- 'README.md'
- 'reme/**'
- 'reme/__init__.py'
- 'reme/utils/web_static.py'
- 'scripts/bump_version.py'
- 'tests/unit/test_package_versions.py'
- 'LICENSE'
pull_request:
branches: [main, master, dev, develop]
@ -20,10 +19,9 @@ on:
- '.github/workflows/_build-python-packages.yml'
- '.github/workflows/release-python.yml'
- 'pyproject.toml'
- 'README.md'
- 'reme/**'
- 'reme/__init__.py'
- 'reme/utils/web_static.py'
- 'scripts/bump_version.py'
- 'tests/unit/test_package_versions.py'
- 'LICENSE'
workflow_dispatch:

View file

@ -17,12 +17,10 @@ jobs:
name: Pre-commit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: pip

View file

@ -24,12 +24,10 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'

View file

@ -38,14 +38,12 @@ jobs:
working-directory: reme_studio
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: actions/setup-node@v6
with:
node-version: "22.22.3"
node-version: "22"
cache: npm
cache-dependency-path: reme_studio/package-lock.json
@ -62,12 +60,10 @@ jobs:
run: npm test
- name: Verify npm package
run: |
npm pack --pack-destination "${RUNNER_TEMP}"
tar -tzf "${RUNNER_TEMP}"/agentscope-ai-reme_studio-*.tgz | grep '^package/dist-static/index.html$'
run: npm pack --dry-run
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: "3.11"
@ -79,12 +75,3 @@ jobs:
python scripts/package_studio.py
python -m build reme_studio --outdir dist/studio
python -m twine check dist/studio/*
STUDIO_WHEEL="$(pwd)/$(ls dist/studio/reme_studio-*.whl)"
python -m venv "${RUNNER_TEMP}/reme-studio-package-smoke"
"${RUNNER_TEMP}/reme-studio-package-smoke/bin/python" -m pip install "${STUDIO_WHEEL}"
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-studio-package-smoke/bin/python" - <<'PY'
from reme_studio import static_dir
assert (static_dir() / "index.html").is_file()
PY

View file

@ -31,11 +31,9 @@ jobs:
working-directory: typescript
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@v6
with:
node-version: '22.22.3'
cache: npm

View file

@ -24,12 +24,10 @@ jobs:
python-version: ["3.11"]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'

View file

@ -13,6 +13,7 @@ on:
- "typescript/README*.md"
- "plugins/*/README*.md"
- "benchmark/*/README*.md"
- "skills/reme_memory/SKILL.md"
- "AGENTS.md"
- ".github/workflows/deploy-docs.yml"
- ".github/workflows/_build-docs.yml"
@ -20,6 +21,8 @@ on:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
@ -30,7 +33,7 @@ jobs:
name: Build documentation
uses: ./.github/workflows/_build-docs.yml
with:
run_tests: true
run_tests: false
upload_pages_artifact: true
permissions:
contents: read
@ -43,10 +46,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
permissions:
pages: write
id-token: write
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
uses: actions/deploy-pages@v5

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check PR title format
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
uses: amannn/action-semantic-pull-request@v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View file

@ -1,7 +1,7 @@
# 发布操作手册:
# 1. 先将 plugins/auto-fin/pyproject.toml 中的 project.version 更新为待发布版本并合入目标分支。
# 2. 确认插件依赖的 reme-ai 版本已经发布到 PyPI本工作流会在构建阶段验证该依赖可下载。
# 3. 确认 PyPI Trusted Publisher 已绑定本仓库、此工作流和 pypi environment,且 PyPI 上不存在相同版本。
# 3. 确认仓库 Actions Secret 已配置 PYPI_API_TOKEN,且 PyPI 上不存在相同版本。
# 4. 在 GitHub 仓库的 Actions 页面选择“Release / Auto Fin plugin”点击“Run workflow”。
# 5. 输入与 project.version 完全一致的版本号(例如 0.1.0)后运行;版本也可以带 v 前缀。
#
@ -34,12 +34,10 @@ jobs:
RELEASE_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: '3.11'
@ -73,10 +71,8 @@ jobs:
if len(requirements) != 1:
raise SystemExit(f"Expected one reme-ai dependency, found {requirements!r}")
reme_requirement = Requirement(requirements[0])
if reme_requirement.name != "reme-ai" or reme_requirement.extras:
raise SystemExit(f"Expected a base reme-ai dependency, found {requirements[0]!r}")
if Version("0.4.1.8") in reme_requirement.specifier or Version("0.4.1.9") not in reme_requirement.specifier:
raise SystemExit(f"Expected reme-ai>=0.4.1.9, found {requirements[0]!r}")
if reme_requirement.name != "reme-ai" or set(reme_requirement.extras) != {"core"}:
raise SystemExit(f"Expected a reme-ai[core] dependency, found {requirements[0]!r}")
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
print(f"reme_requirement={reme_requirement}", file=output)
print(f"Publishing {project['name']} {actual}")
@ -86,12 +82,10 @@ jobs:
run: python -m pytest plugins/auto-fin -q
- name: Require the plugin-enabled ReMe release on PyPI
env:
REME_REQUIREMENT: ${{ steps.package.outputs.reme_requirement }}
run: |
python -m pip download --no-deps \
--dest "${RUNNER_TEMP}/reme-auto-fin-base" \
"${REME_REQUIREMENT}"
--dest "${RUNNER_TEMP}/reme-auto-fin-core" \
"${{ steps.package.outputs.reme_requirement }}"
- name: Build and check distributions
run: |
@ -106,31 +100,20 @@ jobs:
python -m zipfile -l "${AUTO_FIN_WHEEL}" | grep 'dist-info/licenses/LICENSE'
python -m tarfile -l "${AUTO_FIN_SDIST}" | grep '/LICENSE'
python -m venv "${RUNNER_TEMP}/reme-auto-fin-smoke"
"${RUNNER_TEMP}/reme-auto-fin-smoke/bin/python" -m pip install \
"agentscope[model-ollama]==2.0.7" "${AUTO_FIN_WHEEL}"
"${RUNNER_TEMP}/reme-auto-fin-smoke/bin/python" -m pip install "${AUTO_FIN_WHEEL}"
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-auto-fin-smoke/bin/python" - <<'PY'
from importlib.metadata import distribution
from reme.plugin_manifest import load_package_manifest
package = distribution("reme-auto-fin")
plugins = {entry.name: entry for entry in package.entry_points if entry.group == "reme.plugins"}
assert plugins["auto-fin"].value == "reme_auto_fin"
manifest = load_package_manifest("reme_auto_fin", plugin_name="auto-fin")
assert set(manifest.backends) == {
"auto_fin_data_step",
"auto_fin_topic_step",
"auto_fin_merge_step",
}
assert set(manifest.application_defaults["jobs"]) == {
"auto_fin",
"auto_fin_cron",
}
configs = {entry.name: entry for entry in package.entry_points if entry.group == "reme.configs"}
assert plugins["auto-fin"].load().name == "auto-fin"
assert configs["auto-fin"].load().is_file()
PY
- name: Upload distributions
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: reme-auto-fin-${{ inputs.version }}
path: dist/auto-fin/
@ -139,19 +122,17 @@ jobs:
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- name: Download distributions
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
name: reme-auto-fin-${{ inputs.version }}
path: dist/auto-fin
- name: Publish reme-auto-fin
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist/auto-fin

View file

@ -1,7 +1,7 @@
# Release checklist:
# 1. Update project.version in plugins/daily_paper/pyproject.toml and merge it into the target branch.
# 2. Publish the required reme-ai version before this plugin; the build verifies that dependency on PyPI.
# 3. Configure PyPI Trusted Publishing for this repository/workflow and its pypi environment.
# 3. Confirm PYPI_API_TOKEN is configured and the version does not already exist on PyPI.
# 4. Run "Release / Daily Paper plugin" from GitHub Actions with the exact project version (a v prefix is accepted).
#
# Recommended order: reme-ai -> reme-daily-paper -> downstream applications enabling plugins: [daily-paper].
@ -33,12 +33,10 @@ jobs:
RELEASE_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@v6
with:
python-version: '3.11'
@ -70,10 +68,8 @@ jobs:
raise SystemExit(f"Package version is {actual}, but workflow input is {expected}")
requirements = [Requirement(value) for value in project["dependencies"]]
reme_requirements = [requirement for requirement in requirements if requirement.name == "reme-ai"]
if len(reme_requirements) != 1 or reme_requirements[0].extras:
raise SystemExit(f"Expected one base reme-ai dependency, found {reme_requirements!r}")
if Version("0.4.1.8") in reme_requirements[0].specifier or Version("0.4.1.9") not in reme_requirements[0].specifier:
raise SystemExit(f"Expected reme-ai>=0.4.1.9, found {reme_requirements!r}")
if len(reme_requirements) != 1 or set(reme_requirements[0].extras) != {"core"}:
raise SystemExit(f"Expected one reme-ai[core] dependency, found {reme_requirements!r}")
if sum(requirement.name == "pypdf" for requirement in requirements) != 1:
raise SystemExit("Expected exactly one pypdf dependency")
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
@ -85,12 +81,10 @@ jobs:
run: python -m pytest plugins/daily_paper -q
- name: Require the plugin-enabled ReMe release on PyPI
env:
REME_REQUIREMENT: ${{ steps.package.outputs.reme_requirement }}
run: |
python -m pip download --no-deps \
--dest "${RUNNER_TEMP}/reme-daily-paper-base" \
"${REME_REQUIREMENT}"
--dest "${RUNNER_TEMP}/reme-daily-paper-core" \
"${{ steps.package.outputs.reme_requirement }}"
- name: Build and check distributions
run: |
@ -107,8 +101,7 @@ jobs:
python -m zipfile -l "${DAILY_PAPER_WHEEL}" | grep 'dist-info/licenses/LICENSE'
python -m tarfile -l "${DAILY_PAPER_SDIST}" | grep '/LICENSE'
python -m venv "${RUNNER_TEMP}/reme-daily-paper-smoke"
"${RUNNER_TEMP}/reme-daily-paper-smoke/bin/python" -m pip install \
"agentscope[model-ollama]==2.0.7" "${DAILY_PAPER_WHEEL}"
"${RUNNER_TEMP}/reme-daily-paper-smoke/bin/python" -m pip install "${DAILY_PAPER_WHEEL}"
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-daily-paper-smoke/bin/python" - <<'PY'
from importlib.metadata import distribution
@ -130,7 +123,7 @@ jobs:
PY
- name: Upload distributions
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: reme-daily-paper-${{ inputs.version }}
path: dist/daily-paper/
@ -139,19 +132,17 @@ jobs:
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- name: Download distributions
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
name: reme-daily-paper-${{ inputs.version }}
path: dist/daily-paper
- name: Publish reme-daily-paper
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist/daily-paper

View file

@ -1,7 +1,7 @@
name: Release / Python packages
# Configure a PyPI Trusted Publisher for this repository, workflow, and its
# pypi environment before running the manual release.
# reme-ai[qwenpaw] is verified before publication. Publish the independently
# versioned reme-auto-fin and reme-daily-paper requirements first.
on:
workflow_dispatch:
@ -10,38 +10,34 @@ on:
description: Release version
required: true
type: string
release:
types: [published]
permissions:
contents: read
concurrency:
group: publish-reme-ai
cancel-in-progress: false
jobs:
build:
name: Build and verify distributions
uses: ./.github/workflows/_build-python-packages.yml
with:
expected_version: ${{ inputs.version }}
expected_version: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }}
upload_artifacts: true
publish-reme:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- name: Download ReMe distributions
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
name: reme-distributions
path: dist/reme
- name: Publish ReMe
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist/reme
skip-existing: true

View file

@ -1,6 +1,6 @@
# Release checklist:
# 1. Update reme_studio/pyproject.toml, package.json, and package-lock.json to the same Studio version.
# 2. Configure npm Trusted Publishing and PyPI Trusted Publishing with the pypi environment.
# 2. Configure PyPI and npm publishing credentials for this workflow.
# 3. Run this workflow manually with the exact Studio version.
name: Release / ReMe Studio
@ -38,17 +38,15 @@ jobs:
NPM_TAG: ${{ inputs.npm_tag }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@v6
with:
node-version: "22.22.3"
node-version: "22.13"
cache: npm
cache-dependency-path: reme_studio/package-lock.json
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
@ -95,20 +93,7 @@ jobs:
python -m build reme_studio --outdir dist/studio-python
python -m twine check dist/studio-python/*
- name: Verify Studio distributions and isolated installation
run: |
STUDIO_WHEEL="$(pwd)/$(ls dist/studio-python/reme_studio-*.whl)"
tar -tzf dist/studio-npm/*.tgz | grep '^package/dist-static/index.html$'
python -m venv "${RUNNER_TEMP}/reme-studio-package-smoke"
"${RUNNER_TEMP}/reme-studio-package-smoke/bin/python" -m pip install "${STUDIO_WHEEL}"
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/reme-studio-package-smoke/bin/python" - <<'PY'
from reme_studio import static_dir
assert (static_dir() / "index.html").is_file()
PY
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- uses: actions/upload-artifact@v4
with:
name: reme-studio-${{ inputs.version }}
path: |
@ -119,19 +104,17 @@ jobs:
publish-python:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
- uses: actions/download-artifact@v4
with:
name: reme-studio-${{ inputs.version }}
path: dist
- name: Publish ReMe Studio to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist/studio-python
skip-existing: true
@ -142,12 +125,12 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
- uses: actions/download-artifact@v4
with:
name: reme-studio-${{ inputs.version }}
path: dist

View file

@ -47,12 +47,10 @@ jobs:
NPM_TAG: ${{ inputs.npm_tag }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: actions/setup-node@v6
with:
node-version: '22.22.3'
@ -103,7 +101,7 @@ jobs:
npm pack --pack-destination "${RUNNER_TEMP}/reme-typescript-package"
- name: Upload npm tarball
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: agentscope-ai-reme-${{ inputs.version }}
path: ${{ runner.temp }}/reme-typescript-package/*.tgz
@ -118,13 +116,13 @@ jobs:
steps:
- name: Set up Node for npm
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: https://registry.npmjs.org
- name: Download npm tarball
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
name: agentscope-ai-reme-${{ inputs.version }}
path: dist/typescript
@ -151,7 +149,7 @@ jobs:
actions: read
contents: read
id-token: write
uses: openclaw/clawhub/.github/workflows/package-publish.yml@87ca030c30f3cfb78ab15c8e66b5ff1469c8f9c8 # v0.23.3
uses: openclaw/clawhub/.github/workflows/package-publish.yml@v0.23.3
with:
owner: agentscope-ai
family: code-plugin

View file

@ -30,17 +30,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}

View file

@ -64,7 +64,7 @@ reme plugins list --json
To compare installed plugins with one application config:
```bash
reme plugins list --config default
reme plugins list --config daily_cookbook
```
The optional `ENABLED` column reflects only the `plugins` list resolved from that config. A command-line override used
@ -173,8 +173,11 @@ curl -s http://127.0.0.1:2333/auto_fin \
When the application uses an MCP service, service-enabled plugin Jobs appear as MCP tools instead.
Custom application configs must provide the plugin's runtime dependencies, including an `agent_wrapper.default` and
the `search` and `read` Jobs used by Auto Fin.
To add the plugin to another application config, select it explicitly:
```bash
reme start config=daily_cookbook plugins='["auto-fin"]'
```
## Uninstall a plugin

View file

@ -61,7 +61,7 @@ reme plugins list --json
对照某个应用配置查看启用状态:
```bash
reme plugins list --config default
reme plugins list --config daily_cookbook
```
可选的 `ENABLED` 列只反映该配置解析出的 `plugins` 列表。其他运行中进程使用的 CLI override 不是全局启用状态。
@ -167,7 +167,11 @@ curl -s http://127.0.0.1:2333/auto_fin \
当应用使用 MCP service 时,允许对外服务的插件 Job 会显示为 MCP tool。
自定义应用配置需要提供插件的运行依赖,包括 `agent_wrapper.default`,以及 Auto Fin 使用的 `search``read` Jobs。
如果需要将插件叠加到其他应用配置,则显式选择该配置:
```bash
reme start config=daily_cookbook plugins='["auto-fin"]'
```
## 卸载插件

View file

@ -243,6 +243,11 @@ for (const product of productDocuments) {
await cp(path.join(repoDir, product.source, filename), path.join(outputDir, product.source, filename));
}
}
await mkdir(path.join(outputDir, "reme_studio", "public"), { recursive: true });
await cp(
path.join(repoDir, "reme_studio", "public", "og.jpg"),
path.join(outputDir, "reme_studio", "public", "og.jpg"),
);
await writeFile(
path.join(outputDir, "manifest.json"),
`${JSON.stringify({ documents: await buildManifest() }, null, 2)}\n`,

View file

@ -40,13 +40,13 @@ server means one set of background watchers / dream cron across all your Claude
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
```
3. Start the ReMe HTTP server (one time, leave it running):
3. Start the ReMe MCP server (one time, leave it running):
```bash
reme start service.backend=http
reme start service.backend=mcp service.transport=streamable-http
```
The same process serves the JSON Job API and MCP at `http://127.0.0.1:2333/mcp`. To use a different port, start with
It serves `http://127.0.0.1:2333/mcp`. To use a different port, start with
`service.port=<port>` and update the `url` in `.mcp.json` to match.
## Install the plugin

View file

@ -14,7 +14,7 @@ The recall tools come from the `reme` MCP server (surfaced as `mcp__reme__…`):
running:
```
reme start service.backend=http
reme start service.backend=mcp service.transport=streamable-http
```
If the tools are missing, that server is not running — tell the user the command above instead of

View file

@ -17,7 +17,7 @@ through `plugins=["auto-fin"]`.
### 1. Install ReMe and Auto Fin
```bash
python -m pip install "reme-ai[core]>=0.4.1.9"
python -m pip install "reme-ai[core]>=0.4.1.8"
reme plugins install reme-auto-fin
```
@ -49,17 +49,21 @@ curl -s http://127.0.0.1:2333/auto_fin \
-d '{"topics":"黄金,AI,存储芯片"}'
```
The HTTP service also exposes the same Job as the `auto_fin` MCP tool at `/mcp`. The default topics are
When enabled on an MCP service, the same Job is exposed as the `auto_fin` MCP tool. The default topics are
`黄金,机器人,半导体`; an empty value also uses these defaults.
To host the application with both JSON and MCP access:
To host the same application as an MCP service instead:
```bash
reme start plugins='["auto-fin"]' \
service.backend=http
service.backend=mcp service.transport=streamable-http
```
Custom application configs must provide `agent_wrapper.default` and the `search` and `read` Jobs used by Auto Fin.
To add Auto Fin to another application instead, select that config explicitly, for example:
```bash
reme start config=daily_cookbook plugins='["auto-fin"]'
```
## Pipeline
@ -70,7 +74,7 @@ normalize and deduplicate in RuntimeContext
topic Agent selects real news IDs in bounded batches
research Agent uses search + read on historical memory
research Agent uses memory_search + read on historical memory
validate historical wikilinks in code
@ -85,8 +89,8 @@ records outside the window are discarded.
IDs and deduplicates repeated IDs, then preserves the source-news order. If nothing is relevant, the job succeeds as a
skip without writing or sending a report.
`auto_fin_merge_step` receives only selected current news. It exposes `search` and `read`, and keeps current CLS IDs,
times, and titles as plain evidence. The prompt limits
`auto_fin_merge_step` receives only selected current news. It exposes `memory_search` and `read`, instructs the Agent to
search no later than yesterday, and keeps current CLS IDs, times, and titles as plain evidence. The prompt limits
wikilinks to historical Markdown actually used by the Agent; the code-level boundary independently keeps only existing,
workspace-relative Markdown targets. Missing, absolute, escaping, backslash, and self-referential targets are degraded
to their readable aliases.
@ -105,7 +109,7 @@ refreshes the daily index. No JSONL, intermediate Markdown, or structured Agent
| `request_interval` | `10` | Minimum delay in seconds after every CLS request attempt; may be zero |
| `max_retries` | `3` | Maximum attempts for each CLS page request; must be at least one |
The plugin cron Job starts with the application and runs daily at 18:00 in the application timezone.
The three plugin cron Jobs start with the application and run daily at 09:30, 11:30, and 18:00 in `Asia/Shanghai`.
## Output

View file

@ -14,7 +14,7 @@ distribution单个 `reme.plugins` entry point 暴露 `plugin.yaml`,其中
### 1. 安装 ReMe 和 Auto Fin
```bash
python -m pip install "reme-ai[core]>=0.4.1.9"
python -m pip install "reme-ai[core]>=0.4.1.8"
reme plugins install reme-auto-fin
```
@ -44,17 +44,21 @@ curl -s http://127.0.0.1:2333/auto_fin \
-d '{"topics":"黄金,AI,存储芯片"}'
```
HTTP service 也会在 `/mcp` 中将同一个 Job 暴露为 `auto_fin` MCP tool。默认 topics 是 `黄金,机器人,半导体`
在 MCP service 中启用插件时,同一个 Job 会暴露为 `auto_fin` MCP tool。默认 topics 是 `黄金,机器人,半导体`
传入空值也会使用默认值。
如果需要同时通过 JSON 和 MCP 访问同一个应用
如果需要将同一个应用作为 MCP service 启动
```bash
reme start plugins='["auto-fin"]' \
service.backend=http
service.backend=mcp service.transport=streamable-http
```
自定义应用配置需要提供 `agent_wrapper.default`,以及 Auto Fin 使用的 `search``read` Jobs。
如果需要将 Auto Fin 叠加到其他应用,则显式选择相应配置,例如:
```bash
reme start config=daily_cookbook plugins='["auto-fin"]'
```
## 流程
@ -65,7 +69,7 @@ reme start plugins='["auto-fin"]' \
Topic Agent 分批选择真实 news_id
Research Agent 使用 search + read 检索历史记忆
Research Agent 使用 memory_search + read 检索历史记忆
代码校验历史 wikilink
@ -78,8 +82,8 @@ daily/YYYY-MM-DD/auto_fin.md
`auto_fin_topic_step` 分批接收当前新闻,只返回相关的 `news_id`。代码会忽略未知 ID、去除重复 ID并保持源新闻顺序。如果没有相关新闻Job
会成功跳过,不写报告也不发送通知。
`auto_fin_merge_step` 只接收筛选后的当前新闻,并向 Agent 开放 `search` 和 `read`。当前新闻以 CLS ID、时间和标题作为普通证据。
Prompt 要求 Agent 只链接实际使用过的历史 Markdown代码边界则独立保证只保留真实存在、相对
`auto_fin_merge_step` 只接收筛选后的当前新闻,并向 Agent 开放 `memory_search` 和 `read`历史检索截止到昨天;当前新闻以
CLS ID、时间和标题作为普通证据。Prompt 要求 Agent 只链接实际使用过的历史 Markdown代码边界则独立保证只保留真实存在、相对
workspace 的 Markdown 目标。不存在、绝对路径、越界、带反斜杠和自引用的目标都会降级为可读 alias。
同日重跑会参考当天已有报告并覆盖为修订结果。最终写入使用原子替换并刷新当天索引;流程不会写入 JSONL、中间 Markdown 或 Agent
@ -96,7 +100,7 @@ workspace 的 Markdown 目标。不存在、绝对路径、越界、带反斜杠
| `request_interval` | `10` | 每次财联社请求尝试后的最小等待秒数,可设为 0 |
| `max_retries` | `3` | 每页财联社请求的最大尝试次数,至少为 1 |
插件的 cron Job 随应用启动,并按应用配置的时区在每天 18:00 运行。
插件的三个 cron Job 随应用启动,并按 `Asia/Shanghai` 时区在每天 09:30、11:30 和 18:00 运行。
## 产物

View file

@ -1,13 +1,13 @@
[project]
name = "reme-auto-fin"
version = "0.1.2"
version = "0.1.1"
description = "Auto Fin example plugin for ReMe."
readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.11"
dependencies = [
"reme-ai>=0.4.1.9",
"reme-ai[core]>=0.4.1.8",
]
[project.entry-points."reme.plugins"]

View file

@ -76,7 +76,6 @@ class AutoFinStep(BaseStep):
prompt_name: str,
model: type[BaseModel],
job_tools: list[str] | None = None,
injected_job_kwargs: dict[str, Any] | None = None,
**values: str,
) -> BaseModel:
if self.agent_wrapper is None:
@ -90,8 +89,6 @@ class AutoFinStep(BaseStep):
kwargs: dict[str, Any] = {"output_schema": model}
if job_tools:
kwargs["job_tools"] = job_tools
if injected_job_kwargs:
kwargs["injected_job_kwargs"] = injected_job_kwargs
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)}")

View file

@ -102,24 +102,17 @@ class AutoFinMergeStep(AutoFinStep):
)
async def execute(self):
"""Research the selected news and persist the validated report."""
assert self.context is not None
if self.context.get("auto_fin_skipped"):
return self.context.response
run_date = date.fromisoformat(str(self._required("auto_fin_date")))
historical_search = {
"limit": 5,
"min_score": 0.0,
"start_date": None,
"end_date": (run_date - timedelta(days=1)).isoformat(),
}
output = await self._reply(
"merge_user",
AutoFinReportOutput,
job_tools=list(self.kwargs.get("job_tools") or []),
injected_job_kwargs=historical_search,
decision_at=str(self._required("auto_fin_decision_at")),
window_start=str(self._required("auto_fin_window_start")),
historical_end=(run_date - timedelta(days=1)).isoformat(),
topics=json.dumps(self._required("auto_fin_topics"), ensure_ascii=False),
news=json.dumps(self._required("auto_fin_selected_news"), ensure_ascii=False),
current_report=self._current_report(run_date),

View file

@ -1,5 +1,5 @@
merge_user: |
你是主题新闻研究 Agent。当前新闻已经按 topics 做过语义筛选。你可以使用 `search` 搜索历史记忆,
你是主题新闻研究 Agent。当前新闻已经按 topics 做过语义筛选。你可以使用 `memory_search` 搜索历史记忆,
并使用 `read` 阅读可能相关的完整 Markdown。不得使用外部搜索不得虚构行情、收益、价格或未提供的数据。
研究窗口:{window_start} 至 {decision_at}
@ -8,7 +8,7 @@ merge_user: |
今天早些时段的报告(如有,请保留仍成立的判断,只修订变化部分):
{current_report}
先围绕 topics 和当前重要事件多次调用 `search` 检索历史记忆
先围绕 topics 和当前重要事件多次调用 `memory_search`,并将 end_date 设为 {historical_end},避免召回今天的旧报告
只对明显相关的结果调用 `read`。说明历史事件与当前事件的相同点、关键差异,以及旧判断是否仍适用。
给出值得回顾的新闻、应继续观察的信息,以及哪些条件会强化或推翻判断,但不要给出投资建议。

View file

@ -42,9 +42,19 @@ application_defaults:
- backend: auto_fin_data_step
- backend: auto_fin_topic_step
- backend: auto_fin_merge_step
job_tools: [search, read]
job_tools: [memory_search, read]
auto_fin_cron:
auto_fin_0930_cron:
backend: cron
cron: "30 9 * * *"
steps: *auto_fin_steps
auto_fin_1130_cron:
backend: cron
cron: "30 11 * * *"
steps: *auto_fin_steps
auto_fin_1800_cron:
backend: cron
cron: "0 18 * * *"
steps: *auto_fin_steps

View file

@ -195,23 +195,14 @@ async def test_merge_writes_only_final_report_and_validates_historical_links(tmp
response = await AutoFinMergeStep(
app_context=app_context,
agent_wrapper=agent,
job_tools=["search", "read"],
job_tools=["memory_search", "read"],
)(context)
prompt, kwargs = agent.calls[0]
assert "end_date" not in prompt
assert "调用 `search`" in prompt
assert "end_date 设为 2026-08-09" in prompt
assert "调用 `memory_search`" in prompt
assert "调用 `read`" in prompt
assert kwargs == {
"output_schema": AutoFinReportOutput,
"job_tools": ["search", "read"],
"injected_job_kwargs": {
"limit": 5,
"min_score": 0.0,
"start_date": None,
"end_date": "2026-08-09",
},
}
assert kwargs == {"output_schema": AutoFinReportOutput, "job_tools": ["memory_search", "read"]}
report = (tmp_path / "daily" / "2026-08-10" / "auto_fin.md").read_text(encoding="utf-8")
assert "[[daily/2026-08-01/auto_fin.md|历史黄金观察]]" in report
assert "](daily/2026-08-01/auto_fin.md)" not in report
@ -263,17 +254,14 @@ def test_plugin_config_has_default_topics_and_no_intermediate_index_step():
"auto_fin_topic_step",
"auto_fin_merge_step",
]
assert job["steps"][2]["job_tools"] == ["search", "read"]
assert jobs["auto_fin_cron"]["cron"] == "0 18 * * *"
assert jobs["auto_fin_cron"]["steps"] == job["steps"]
assert (
not {
"auto_fin_0930_cron",
"auto_fin_1130_cron",
"auto_fin_1800_cron",
}
& jobs.keys()
)
assert job["steps"][2]["job_tools"] == ["memory_search", "read"]
for name, schedule in {
"auto_fin_0930_cron": "30 9 * * *",
"auto_fin_1130_cron": "30 11 * * *",
"auto_fin_1800_cron": "0 18 * * *",
}.items():
assert jobs[name]["cron"] == schedule
assert jobs[name]["steps"] == job["steps"]
def test_agent_schemas_are_small_and_required():

View file

@ -13,7 +13,7 @@ their Job configuration under `application_defaults`. Enable the installed plugi
### 1. Install ReMe and Daily Paper
```bash
python -m pip install "reme-ai[core]>=0.4.1.9"
python -m pip install "reme-ai[core]>=0.4.1.8"
reme plugins install reme-daily-paper
```
@ -62,7 +62,7 @@ rank with RRF and let an Agent select three papers
download and parse arXiv PDFs, then write three Chinese analyses
use search + read to connect prior memory and generate a brief
use memory_search + read to connect prior memory and generate a brief
refresh the daily index and optionally send the brief to DingTalk
```
@ -80,7 +80,7 @@ the configured page, character, and file-size limits. It writes the three Chines
PDFs and files without a text layer fail explicitly.
`daily_paper_digest_step` treats those three analyses as the factual source and receives only the read-only
`search` and `read` tools for linking earlier memory. Code validates historical wikilinks, appends links to all
`memory_search` and `read` tools for linking earlier memory. Code validates historical wikilinks, appends links to all
three source notes, and rebuilds the daily index. The optional `dingtalk_markdown_send_step` sends the final brief when
conversation IDs are configured and otherwise skips without side effects.

View file

@ -11,7 +11,7 @@ Step backend并在 `application_defaults` 下提供 Job 配置;通过 `plug
### 1. 安装 ReMe 和每日论文插件
```bash
python -m pip install "reme-ai[core]>=0.4.1.9"
python -m pip install "reme-ai[core]>=0.4.1.8"
reme plugins install reme-daily-paper
```
@ -58,7 +58,7 @@ RRF 排序后由 Agent 精选三篇
下载并解析 arXiv PDF生成三篇中文解读
使用 search + read 关联历史记忆并生成简报
使用 memory_search + read 关联历史记忆并生成简报
写入当日索引,并按需发送到钉钉
```
@ -72,7 +72,7 @@ RRF 排序后由 Agent 精选三篇
`daily_paper_analyze_step` 下载 PDF 到 `resource/papers/`,复用已有的有效文件,并在页数、字符数和文件大小限制内提取
文本。三篇中文解读按精选顺序写入当天目录;扫描版或没有文本层的 PDF 会明确失败。
`daily_paper_digest_step` 以本次生成的三篇解读为事实来源,只开放只读的 `search` 和 `read` 来关联较早记忆。
`daily_paper_digest_step` 以本次生成的三篇解读为事实来源,只开放只读的 `memory_search` 和 `read` 来关联较早记忆。
代码会校验历史 wikilink、追加三篇源笔记链接并重建当日索引。可选的 `dingtalk_markdown_send_step` 在配置群会话后
发送最终简报;未配置时无副作用跳过。

View file

@ -1,6 +1,6 @@
[project]
name = "reme-daily-paper"
version = "0.1.2"
version = "0.1.1"
description = "Daily Paper research and reading-note plugin for ReMe."
readme = "README.md"
license = "Apache-2.0"
@ -8,7 +8,7 @@ license-files = ["LICENSE"]
requires-python = ">=3.11"
dependencies = [
"pypdf>=5.0.0",
"reme-ai>=0.4.1.9",
"reme-ai[core]>=0.4.1.8",
]
[project.entry-points."reme.plugins"]

View file

@ -64,7 +64,6 @@ class DailyPaperDigestStep(DailyPaperStep):
return _WIKILINK_RE.sub(replace, body)
async def execute(self):
"""Generate and persist the final brief from analyzed papers."""
assert self.context is not None
if self._skip():
self.logger.info(f"[{self.name}] skip existing digest")
@ -80,23 +79,16 @@ class DailyPaperDigestStep(DailyPaperStep):
documents = [{"title": item.title, "desc": item.desc, "body": item.body} for item in analyses]
wikilinks = [f"[[{item.note_path}]]" for item in analyses]
run_day = dt.date.fromisoformat(self._run_day())
daily_dir = str(self.config_value("daily_dir")).strip("/")
previous_day = (dt.date.fromisoformat(self._run_day()) - dt.timedelta(days=1)).isoformat()
self.logger.info(f"[{self.name}] agent start notes={len(analyses)}")
result = await self.agent_wrapper.reply(
self.prompt_format(
"digest_user",
documents=json.dumps(documents, ensure_ascii=False, indent=2),
daily_dir=daily_dir,
previous_day=previous_day,
),
output_schema=DailyPaperMarkdownOutput,
job_tools=list(self.kwargs.get("job_tools") or []),
injected_job_kwargs={
"limit": 20,
"min_score": 0.0,
"start_date": None,
"end_date": (run_day - dt.timedelta(days=1)).isoformat(),
},
)
self.logger.info(f"[{self.name}] agent done notes={len(analyses)}")
output = structured_output(result, DailyPaperMarkdownOutput)
@ -104,7 +96,8 @@ class DailyPaperDigestStep(DailyPaperStep):
if not output.desc.strip() or not body:
raise ValueError("Agent returned an empty daily paper brief")
day = run_day.isoformat()
day = self._run_day()
daily_dir = str(self.config_value("daily_dir")).strip("/")
title = normalize_chinese_title(output.title, f"每日论文简报-{day}")
existing_rel = str(self._state("existing_digest_path") or "").strip()
existing_path = self.workspace_path / existing_rel if existing_rel else None
@ -117,7 +110,7 @@ class DailyPaperDigestStep(DailyPaperStep):
existing=existing_path,
)
digest_rel = digest_path.relative_to(self.workspace_path).as_posix()
body = self._validate_historical_wikilinks(body, run_day, digest_path)
body = self._validate_historical_wikilinks(body, dt.date.fromisoformat(day), digest_path)
body += "\n\n## 详细论文\n\n" + "\n".join(f"- {link}" for link in wikilinks)
selected_ids = [item.arxiv_id for item in analyses]
await write_markdown(

View file

@ -4,13 +4,12 @@ digest_user: |
内容只能依据输入文档,不得补充文档中没有提供的事实。
保留技术准确性,同时解释三篇论文为什么值得关注,以及它们之间有什么联系。
在写作前,先调用 `search` 检索已有记忆:围绕三篇论文的核心问题、方法、关键词和同义表达组织查询。
主题跨度较大时可以多次检索,搜索结果不必局限于 `{daily_dir}/`。只有 `{daily_dir}/` 下日期早于今天、
且与本期内容确实相似或互补的 Markdown 文章才可作为正文中的历史链接候选;必要时调用 `read` 核验全文,
不要仅凭标题判断。
在写作前,先调用 `memory_search` 检索以前的文章:围绕三篇论文的核心问题、方法、关键词和同义表达组织查询,
使用 end_date={previous_day}、limit=20。主题跨度较大时可以多次检索。只把 `daily/` 下日期早于今天、
且与本期内容确实相似或互补的 Markdown 文章作为候选;必要时调用 `read` 核验全文,不要仅凭标题判断。
将确认相关的旧文章以 Wikilink 自然织入正文,并用句子说明关联(延续、对比、补充或方法相似);
链接必须采用带 `.md` 的完整 workspace-relative 路径,例如
`[[{daily_dir}/2026-07-01/旧文章.md|此前的相关解读]]`。不要输出裸链接、独立关系字段,也不要虚构搜索未命中的路径。
`[[daily/2026-07-01/旧文章.md|此前的相关解读]]`。不要输出裸链接、独立关系字段,也不要虚构搜索未命中的路径。
旧文章只用于判断关联和建立链接,不得用来补充本期事实。如果没有真正相关的旧文章,不要强行添加;
当日三篇详细解读的链接会由系统统一附在文末。

View file

@ -53,7 +53,7 @@ application_defaults:
- backend: daily_paper_select_step
- backend: daily_paper_analyze_step
- backend: daily_paper_digest_step
job_tools: [search, read]
job_tools: [memory_search, read]
- backend: dingtalk_markdown_send_step
input_mapping:
daily_paper_digest_path: markdown_path

View file

@ -627,27 +627,6 @@ def test_daily_paper_cron_hf_mirror_defaults_enabled_with_environment_override(m
assert _plugin_config()["jobs"]["daily_paper_cron"]["use_hf_mirror"] is False
def test_digest_prompt_uses_configured_daily_directory(tmp_path: Path):
"""Use the host application's daily directory in historical-link guidance."""
step = DailyPaperDigestStep(
app_context=ApplicationContext(
workspace_dir=str(tmp_path),
daily_dir="memory",
),
)
prompt = step.prompt_format(
"digest_user",
documents="[]",
daily_dir=str(step.config_value("daily_dir")).strip("/"),
)
assert "`memory/`" in prompt
assert "搜索结果不必局限于 `memory/`" in prompt
assert "[[memory/2026-07-01/旧文章.md" in prompt
assert "[[daily/2026-07-01/" not in prompt
def test_paper_pick_list_uses_an_object_root_for_tool_output():
"""AgentScope function arguments require an object-root JSON schema."""
schema = PaperPickList.model_json_schema()
@ -867,7 +846,7 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
await DailyPaperDigestStep(
app_context=app_context,
agent_wrapper=cc_wrapper,
job_tools=["search", "read"],
job_tools=["memory_search", "read"],
)(context)
assert _FakeHfClient.requested_daily == ["2026-07-20"]
@ -907,13 +886,7 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
assert all(call["kwargs"] == {"output_schema": DailyPaperMarkdownOutput} for call in cc_wrapper.calls[1:-1])
assert cc_wrapper.calls[-1]["kwargs"] == {
"output_schema": DailyPaperMarkdownOutput,
"job_tools": ["search", "read"],
"injected_job_kwargs": {
"limit": 20,
"min_score": 0.0,
"start_date": None,
"end_date": "2026-07-20",
},
"job_tools": ["memory_search", "read"],
}
assert [call["kwargs"]["output_schema"] for call in cc_wrapper.calls] == [
PaperPickList,
@ -933,10 +906,8 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
assert "调用 Read" not in digest_prompt
assert "daily/2026-07-21" not in digest_prompt
assert "长期记忆" not in digest_prompt
assert "先调用 `search` 检索已有记忆" in digest_prompt
assert "搜索结果不必局限于 `daily/`" in digest_prompt
assert "end_date" not in digest_prompt
assert "limit=" not in digest_prompt
assert "先调用 `memory_search` 检索以前的文章" in digest_prompt
assert "end_date=2026-07-20" in digest_prompt
assert "Wikilink" in digest_prompt
rerun = RuntimeContext(date="2026-07-21")

View file

@ -42,7 +42,7 @@ dependencies = [
[project.optional-dependencies]
as = [
"agentscope[model-ollama]==2.0.7",
"agentscope[model-ollama]==2.0.6",
]
web = [
"reme_studio",
@ -62,6 +62,11 @@ core = [
"polars>=1.43.0",
"reme_studio",
]
qwenpaw = [
"reme-ai[core]",
"reme-auto-fin>=0.1.1",
"reme-daily-paper>=0.1.1",
]
dev = [
"packaging>=24.2",
"pre-commit>=4.6.1",

View file

@ -1,6 +1,6 @@
"""ReMe CLI package."""
__version__ = "0.4.1.9"
__version__ = "0.4.1.8"
from . import config
from . import constants

View file

@ -12,7 +12,6 @@ from ..component_registry import R
from ..as_embedding import BaseAsEmbedding
Miss = tuple[int, str, str] # (result_index, text, cache_key)
_MAX_VECTOR_SPACE_ATTEMPTS = 3
@R.register("local")
@ -104,25 +103,12 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
# -- Public API --
async def get_embeddings(self, input_text: list[str], **kwargs) -> list[np.ndarray | None]:
await self._sync_cache_space()
texts = [self._truncate(t) for t in input_text]
for attempt in range(1, _MAX_VECTOR_SPACE_ATTEMPTS + 1):
await self._sync_cache_space()
vector_space_id = self._cache_space
results, misses = self._partition_by_cache(texts)
stable = not misses or await self._fill_misses(misses, results, vector_space_id, **kwargs)
if stable and vector_space_id == self.vector_space_id == self._cache_space:
return results
if attempt == _MAX_VECTOR_SPACE_ATTEMPTS:
self.logger.warning(
f"Embedding vector space kept changing while computing a request; "
f"discarding all result(s) after {attempt} attempts",
)
else:
self.logger.info(
f"Embedding vector space changed while computing a request; "
f"discarding all result(s) and retrying ({attempt}/{_MAX_VECTOR_SPACE_ATTEMPTS})",
)
return [None] * len(texts)
results, misses = self._partition_by_cache(texts)
if misses:
await self._fill_misses(misses, results, **kwargs)
return results
# -- Batching --
@ -138,26 +124,15 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
misses.append((idx, text, key))
return results, misses
async def _fill_misses(
self,
misses: list[Miss],
results: list[np.ndarray | None],
vector_space_id: str,
**kwargs,
) -> bool:
"""Fill every miss only while the request remains in one vector space."""
async def _fill_misses(self, misses: list[Miss], results: list[np.ndarray | None], **kwargs) -> None:
vector_space_id = self._cache_space
size = self.max_batch_size
for start in range(0, len(misses), size):
if vector_space_id != self.vector_space_id or vector_space_id != self._cache_space:
return False
batch = misses[start : start + size]
computed = await self._compute_batch(batch, **kwargs)
if vector_space_id != self.vector_space_id or vector_space_id != self._cache_space:
return False
for idx, key, emb in computed:
for idx, key, emb in await self._compute_batch(batch, **kwargs):
results[idx] = emb
self._cache_put(key, emb)
return True
if vector_space_id == self.vector_space_id == self._cache_space:
self._cache_put(key, emb)
async def _compute_batch(self, batch: list[Miss], **kwargs) -> list[tuple[int, str, np.ndarray]]:
texts = [text for _, text, _ in batch]

View file

@ -1,17 +1,16 @@
"""HTTP service: expose jobs through JSON/SSE endpoints and MCP tools."""
"""HTTP service: exposes jobs as FastAPI endpoints (JSON, or SSE for stream jobs)."""
import asyncio
import warnings
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from starlette.routing import Route
from .base_service import BaseService
from ..component_registry import R
@ -19,7 +18,6 @@ from ..job import BaseJob, StreamJob
from ...constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ...schema import Request, Response
from ...utils import execute_stream_task, resolve_web_static_dir
from .mcp_tools import add_mcp_job
if TYPE_CHECKING:
from ...application import Application
@ -35,7 +33,7 @@ _WEBSOCKET_DEPRECATION_PATTERNS = (
@R.register("http")
class HttpService(BaseService):
"""Expose jobs through JSON/SSE endpoints and streamable HTTP MCP."""
"""Map non-stream jobs to JSON POST endpoints and StreamJobs to SSE endpoints."""
def __init__(
self,
@ -43,11 +41,6 @@ class HttpService(BaseService):
port: int = REME_DEFAULT_PORT,
web_enabled: bool = True,
web_static_dir: str | None = None,
mcp_enabled: bool = True,
mcp_path: str = "/mcp",
mcp_stateless_http: bool = False,
injected_job_kwargs: dict[str, Any] | None = None,
tool_error_on_failure: bool = False,
**kwargs,
):
super().__init__(**kwargs)
@ -55,34 +48,14 @@ class HttpService(BaseService):
self.port: int = port
self.web_enabled = web_enabled
self.web_static_dir = web_static_dir
self.mcp_enabled = mcp_enabled
self.mcp_path = self._validate_mcp_path(mcp_path)
self.mcp_stateless_http = mcp_stateless_http
self.injected_job_kwargs = dict(injected_job_kwargs or {})
self.tool_error_on_failure = tool_error_on_failure
self.mcp_server = None
self.mcp_app = None
# ----- BaseService contract ------------------------------------------
def build_service(self, app: "Application") -> None:
"""Create one FastAPI app containing JSON/SSE and optional MCP routes."""
lifespan = self._lifespan(app, self.host, self.port)
if self.mcp_enabled:
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
self.mcp_server = FastMCP(name=app.config.app_name)
self.mcp_app = self.mcp_server.http_app(
path=self.mcp_path,
transport="streamable-http",
stateless_http=self.mcp_stateless_http,
)
lifespan = combine_lifespans(lifespan, self.mcp_app.lifespan)
"""Create the FastAPI app with permissive CORS and an app-managed lifespan."""
self.service = FastAPI(
title=app.config.app_name,
lifespan=lifespan,
lifespan=self._lifespan(app, self.host, self.port),
)
cors_origins = ["*"]
self.service.add_middleware(
@ -92,60 +65,19 @@ class HttpService(BaseService):
allow_methods=["*"],
allow_headers=["*"],
)
if self.mcp_app is not None:
# Forward the exact path to the complete FastMCP ASGI app. Copying
# only its routes would bypass its middleware and application state;
# mounting it would make the trailing-slash path canonical instead.
self.service.router.routes.append(
Route(
self.mcp_path,
endpoint=self.mcp_app,
include_in_schema=False,
),
)
def add_jobs(self, app: "Application") -> None:
"""Validate reserved routes before the shared tolerant registration loop."""
if self.mcp_enabled:
conflicts = sorted(
job.name
for name, job in app.context.jobs.items()
if job.enable_serve and (self.jobs is None or name in self.jobs) and f"/{job.name}" == self.mcp_path
)
if conflicts:
names = ", ".join(conflicts)
raise ValueError(
f"Job name conflicts with the MCP endpoint {self.mcp_path!r}: {names}",
)
super().add_jobs(app)
def add_job(self, job: BaseJob) -> bool:
"""Register HTTP routes for every job and MCP tools for non-stream jobs."""
if self.mcp_enabled and f"/{job.name}" == self.mcp_path:
raise ValueError(
f"Job name '{job.name}' conflicts with the MCP endpoint {self.mcp_path!r}",
)
"""Dispatch to streaming or non-streaming registration based on job type."""
if isinstance(job, StreamJob):
self._add_stream_job(job)
else:
self._add_json_job(job)
if self.mcp_server is not None:
add_mcp_job(
self.mcp_server,
job,
injected_job_kwargs=self.injected_job_kwargs,
tool_error_on_failure=self.tool_error_on_failure,
)
return True
def start_service(self, app: "Application") -> None:
"""Run uvicorn, suppressing unrelated websocket deprecation noise."""
for pattern in _WEBSOCKET_DEPRECATION_PATTERNS:
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=pattern,
)
warnings.filterwarnings("ignore", category=DeprecationWarning, message=pattern)
uvicorn.run(self.service, host=self.host, port=self.port, **self.kwargs)
def finalize_service(self, app: "Application") -> None:
@ -196,23 +128,6 @@ class HttpService(BaseService):
# ----- Endpoint factories --------------------------------------------
@staticmethod
def _validate_mcp_path(path: str) -> str:
"""Return a canonical, non-reserved absolute path for the MCP endpoint."""
if not path.startswith("/") or path == "/" or path.endswith("/"):
raise ValueError(
"mcp_path must start with '/', must not be '/', and must not end with '/'",
)
if "//" in path or any(segment in {".", ".."} for segment in path.split("/")):
raise ValueError("mcp_path must use non-empty literal path segments")
if any(char in path for char in "{}?#%\\") or any(
char.isspace() or ord(char) < 32 or ord(char) == 127 for char in path
):
raise ValueError("mcp_path must be a literal URL path without route, query, or fragment syntax")
if path in {"/assets", "/docs", "/redoc", "/openapi.json"}:
raise ValueError(f"mcp_path conflicts with reserved HTTP path {path!r}")
return path
def _add_json_job(self, job: BaseJob) -> None:
"""Register a job as POST /{job.name} returning a JSON Response."""

View file

@ -4,9 +4,8 @@ from typing import TYPE_CHECKING, Any
from .base_service import BaseService
from ..component_registry import R
from ..job import BaseJob
from ..job import BaseJob, StreamJob
from ...constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from .mcp_tools import add_mcp_job
if TYPE_CHECKING:
from fastmcp.server.server import Transport
@ -46,12 +45,41 @@ class MCPService(BaseService):
def add_job(self, job: BaseJob) -> bool:
"""Register a non-stream job as an MCP tool; StreamJobs are unsupported."""
return add_mcp_job(
self.service,
job,
injected_job_kwargs=self.injected_job_kwargs,
tool_error_on_failure=self.tool_error_on_failure,
from fastmcp.exceptions import ToolError
from fastmcp.tools import FunctionTool
if isinstance(job, StreamJob):
return False
async def execute_tool(**kwargs):
conflicts = sorted(self.injected_job_kwargs.keys() & kwargs.keys())
if conflicts:
names = ", ".join(conflicts)
raise ToolError(f"{names} injected by the MCP server and cannot be provided by the caller")
kwargs.update(self.injected_job_kwargs)
response = await job(**kwargs)
if self.tool_error_on_failure and not response.success:
raise ToolError(str(response.answer))
return response.answer
parameters = dict(job.parameters or {})
injected_names = self.injected_job_kwargs.keys()
if "properties" in parameters:
parameters["properties"] = {
name: schema for name, schema in parameters["properties"].items() if name not in injected_names
}
if "required" in parameters:
parameters["required"] = [name for name in parameters["required"] if name not in injected_names]
self.service.add_tool(
FunctionTool(
name=job.name,
description=job.description,
fn=execute_tool,
parameters=parameters,
),
)
return True
def start_service(self, app: "Application") -> None:
"""Run the MCP server; bind host/port only for network transports."""
@ -59,8 +87,4 @@ class MCPService(BaseService):
if self.transport != "stdio":
transport_kwargs["host"] = self.host
transport_kwargs["port"] = self.port
self.service.run(
transport=self.transport,
show_banner=False,
**transport_kwargs,
)
self.service.run(transport=self.transport, show_banner=False, **transport_kwargs)

View file

@ -1,52 +0,0 @@
"""Shared MCP tool registration for services that expose ReMe jobs."""
from typing import Any
from ..job import BaseJob, StreamJob
def add_mcp_job(
server: Any,
job: BaseJob,
*,
injected_job_kwargs: dict[str, Any],
tool_error_on_failure: bool,
) -> bool:
"""Register a non-stream job as an MCP tool on ``server``."""
from fastmcp.exceptions import ToolError
from fastmcp.tools import FunctionTool
if isinstance(job, StreamJob):
return False
async def execute_tool(**kwargs):
conflicts = sorted(injected_job_kwargs.keys() & kwargs.keys())
if conflicts:
names = ", ".join(conflicts)
raise ToolError(
f"{names} injected by the MCP server and cannot be provided by the caller",
)
kwargs.update(injected_job_kwargs)
response = await job(**kwargs)
if tool_error_on_failure and not response.success:
raise ToolError(str(response.answer))
return response.answer
parameters = dict(job.parameters or {})
injected_names = injected_job_kwargs.keys()
if "properties" in parameters:
parameters["properties"] = {
name: schema for name, schema in parameters["properties"].items() if name not in injected_names
}
if "required" in parameters:
parameters["required"] = [name for name in parameters["required"] if name not in injected_names]
server.add_tool(
FunctionTool(
name=job.name,
description=job.description,
fn=execute_tool,
parameters=parameters,
),
)
return True

View file

@ -0,0 +1,416 @@
app_name: ReMe Daily Cookbook
workspace_dir: ${DAILY_PAPER_WORKSPACE_DIR:-reme_workspace}
timezone: Asia/Shanghai
language: zh
# This is a standalone application config. It intentionally does not inherit
# default.yaml and listens on a separate port so it can run beside ReMe.
service:
backend: http
host: ${DAILY_PAPER_HOST:-127.0.0.1}
port: ${DAILY_PAPER_PORT:-8001}
jobs:
index_update_loop:
backend: background
watch_dirs: [daily_dir, digest_dir]
watch_suffixes: [md, jsonl]
steps:
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
- backend: watch_changes_step
dispatch_steps:
- backend: update_index_step
persist: false
auto_dream:
backend: base
description: "Auto-dream: consolidate recent daily notes into digest memory and interest topics."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD to scan; defaults to today in the configured timezone."
default: ""
hint:
type: string
description: "Optional guidance for extraction and integration."
default: ""
scan_days:
type: integer
description: "Number of recent daily directories to scan."
default: 2
max_units:
type: integer
description: "Maximum number of extracted memory units."
default: 5
topic_count:
type: integer
description: "Maximum number of interest topics to write."
default: 3
topic_diversity_days:
type: integer
description: "Previous interest-topic days used for de-duplication."
default: 7
steps:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
file_catalog: dream
auto_memory:
backend: base
description: "Auto-memory: record conversation facts into a daily note."
parameters:
type: object
properties:
messages:
type: array
description: "Conversation messages."
items:
type: object
session_id:
type: string
description: "Source conversation session identifier."
default: ""
memory_hint:
type: string
description: "Optional memory-writing guidance."
date:
type: string
description: "YYYY-MM-DD daily-note date; empty infers it from messages or current time."
default: ""
required: [messages]
steps:
- backend: auto_memory_step
reindex:
backend: base
description: "Wipe the derived search store and rebuild it from memory files."
watch_dirs: [daily_dir, digest_dir]
watch_suffixes: [md, jsonl]
parameters:
type: object
properties: {}
steps:
- backend: clear_store_step
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
memory_search:
backend: base
description: "Long-term memory retrieval via hybrid workspace search (vector + BM25, RRF-fused)."
parameters:
type: object
properties:
query:
type: string
description: "Search query."
limit:
type: integer
description: "Maximum number of results."
default: 5
min_score:
type: number
description: "Minimum fused score."
default: 0.0
start_date:
type: string
description: "Optional inclusive start date (YYYY-MM-DD)."
end_date:
type: string
description: "Optional inclusive end date (YYYY-MM-DD)."
required: [query]
steps:
- backend: search_step
vector_weight: 0.7
candidate_multiplier: 5.0
expand_links: true
max_links_per_direction: 10
node_search:
backend: base
description: "Recall digest nodes for auto-dream de-duplication and linking."
parameters:
type: object
properties:
query:
type: string
description: "Candidate memory-node name and description."
limit:
type: integer
description: "Maximum number of digest nodes."
default: 20
required: [query]
steps:
- backend: node_search_step
vector_weight: 0.7
candidate_multiplier: 5.0
daily_list:
backend: base
description: "List notes under one day."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD; empty means today."
default: ""
steps:
- backend: daily_list_step
frontmatter_read:
backend: base
description: "Read a file's frontmatter."
parameters:
type: object
properties:
path:
type: string
description: "Workspace-relative path."
required: [path]
steps:
- backend: frontmatter_read_step
frontmatter_update:
backend: base
description: "Merge key-values into a file's frontmatter."
parameters:
type: object
properties:
path:
type: string
description: "Workspace-relative path."
metadata:
type: object
description: "Key-values to merge."
required: [path, metadata]
steps:
- backend: frontmatter_update_step
move:
backend: base
description: "Move or rename a workspace file and retarget inbound wikilinks."
parameters:
type: object
properties:
src_path:
type: string
description: "Workspace-relative source path."
dst_path:
type: string
description: "Workspace-relative destination path."
overwrite:
type: boolean
default: false
retarget:
type: boolean
default: true
required: [src_path, dst_path]
steps:
- backend: move_step
read:
backend: base
description: "Read a markdown file under the workspace."
parameters:
type: object
properties:
path:
type: string
description: "Workspace-relative markdown path."
start_line:
type: integer
end_line:
type: integer
required: [path]
steps:
- backend: read_step
with_neighbors: false
max_neighbors_per_direction: 10
write:
backend: base
description: "Create or overwrite a markdown file with frontmatter."
parameters:
type: object
properties:
path:
type: string
description: "Workspace-relative markdown path."
name:
type: string
description: "Frontmatter name."
description:
type: string
description: "Frontmatter description."
content:
type: string
description: "Markdown body."
metadata:
type: object
description: "Optional extra frontmatter fields."
required: [path, name, description, content]
steps:
- backend: write_step
daily_write:
backend: base
description: "Write a daily markdown note linked to its source conversation."
parameters:
type: object
properties:
name:
type: string
description: "Filename stem and frontmatter name."
description:
type: string
description: "Frontmatter description."
session_id:
type: string
description: "Source conversation session identifier."
content:
type: string
description: "Markdown body."
date:
type: string
description: "YYYY-MM-DD; empty means today."
default: ""
metadata:
type: object
description: "Optional extra frontmatter fields."
required: [name, description, session_id, content]
steps:
- backend: daily_write_step
edit:
backend: base
description: "Find and replace text in a markdown file."
parameters:
type: object
properties:
path:
type: string
description: "Workspace-relative path."
old:
type: string
description: "Text to replace."
new:
type: string
description: "Replacement text."
default: ""
required: [path, old, new]
steps:
- backend: edit_step
dingtalk_wait:
backend: background
supervisor: true
close_timeout: 10
steps:
- backend: dingtalk_wait_step
app_key: ${DINGTALK_APP_KEY:-}
app_secret: ${DINGTALK_APP_SECRET:-}
robot_code: ${DINGTALK_ROBOT_CODE:-}
worker_count: 4
builtin_tools: [bash]
job_tools:
- memory_search
- read
- write
- edit
- daily_list
- daily_write
- frontmatter_read
- frontmatter_update
components:
tokenizer:
default:
backend: regex
as_llm:
default:
backend: openai
model: ${LLM_MODEL_NAME:-qwen3.7-plus}
stream: true
context_size: 200000
max_retries: 3
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
agent_wrapper:
default:
backend: agentscope
as_llm: default
builtin_tools: false
# as_embedding:
# default:
# backend: openai
# model: ${EMBEDDING_MODEL_NAME:-text-embedding-v4}
# dimensions: 1024
# max_retries: 0
# credential:
# api_key: ${EMBEDDING_API_KEY:-}
# base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
# parameters: {}
#
# embedding_store:
# default:
# backend: local
# as_embedding: default
# max_retries: 3
# quota_retry_delay: 60.0
file_graph:
default:
backend: local
file_catalog:
dream:
backend: local
file_chunker:
markdown:
backend: markdown
supported_extensions: [md]
embed_toc: true
max_ast_sections: 100
include_frontmatter_in_metadata: false
include_frontmatter_keys_in_metadata: []
jsonl:
backend: jsonl
supported_extensions: [jsonl]
max_lines_per_chunk: 1
keyword_index:
default:
backend: bm25
tokenizer: default
file_store:
default:
backend: local
store_name: local
# embedding_store: default
embedding_store: ""
keyword_index: default
file_graph: default

View file

@ -1,8 +1,6 @@
service:
backend: http
web_enabled: true
mcp_enabled: true
mcp_path: /mcp
jobs:
index_update_loop:

View file

@ -79,13 +79,7 @@ def print_logo(app_config: "ApplicationConfig", runtime_service: "BaseService |
host = getattr(runtime_service, "host", extra.get("host", REME_DEFAULT_HOST))
port = getattr(runtime_service, "port", extra.get("port", REME_DEFAULT_PORT))
info_table.add_row("🔗", "URL:", f"http://{host}:{port}")
mcp_enabled = getattr(runtime_service, "mcp_enabled", extra.get("mcp_enabled", True))
if mcp_enabled:
mcp_path = getattr(runtime_service, "mcp_path", extra.get("mcp_path", "/mcp"))
info_table.add_row("🚌", "MCP:", f"http://{host}:{port}{mcp_path}")
info_table.add_row("📚", "FastAPI:", Text(get_version("fastapi"), style="dim"))
if mcp_enabled:
info_table.add_row("📚", "FastMCP:", Text(get_version("fastmcp"), style="dim"))
case "mcp":
transport = getattr(runtime_service, "transport", extra.get("transport", "sse"))
info_table.add_row("🚌", "Transport:", transport)

View file

@ -6,7 +6,7 @@ ReMe Studio is the local web workspace for ReMe. It lets you browse and edit use
links, and chat with the ReMe Agent without moving durable memory into a separate application database. Search indexes,
graphs, and other derived metadata remain rebuildable from the source files.
![ReMe Studio workspace](https://github.com/user-attachments/assets/7d0db0d4-69c5-49ef-b1ca-ef8c6cab1138)
![ReMe Studio workspace](https://raw.githubusercontent.com/agentscope-ai/ReMe/main/reme_studio/public/og.jpg)
## Installation

View file

@ -5,7 +5,7 @@
ReMe Studio 是 ReMe 的本地 Web 工作区。你可以在这里浏览和编辑自己拥有的工作区文件、探索记忆之间的联系,并与 ReMe Agent
对话,而无需将持久记忆迁移到独立的应用数据库中。搜索索引、图谱和其他派生元数据均可根据源文件重建。
![ReMe Studio 工作区](https://github.com/user-attachments/assets/7d0db0d4-69c5-49ef-b1ca-ef8c6cab1138)
![ReMe Studio 工作区](https://raw.githubusercontent.com/agentscope-ai/ReMe/main/reme_studio/public/og.jpg)
## 安装

View file

@ -9,9 +9,6 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const remeStudioOgImage =
"https://github.com/user-attachments/assets/7d0db0d4-69c5-49ef-b1ca-ef8c6cab1138";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host =
@ -34,9 +31,9 @@ export async function generateMetadata(): Promise<Metadata> {
description: "本地优先的 Agent 记忆工作区",
images: [
{
url: remeStudioOgImage,
width: 2400,
height: 1252,
url: "/og.jpg",
width: 1200,
height: 626,
alt: "ReMe Studio memory workspace",
},
],
@ -45,7 +42,7 @@ export async function generateMetadata(): Promise<Metadata> {
card: "summary_large_image",
title: "ReMe Studio",
description: "本地优先的 Agent 记忆工作区",
images: [remeStudioOgImage],
images: ["/og.jpg"],
},
};
}

BIN
reme_studio/public/og.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -19,8 +19,6 @@ def test_load_builtin_config_by_filename_with_suffix():
cfg = _load_config("default.yaml")
assert cfg["service"]["backend"] == "http"
assert cfg["service"]["mcp_enabled"] is True
assert cfg["service"]["mcp_path"] == "/mcp"
@pytest.mark.parametrize("provider_count", [1, 2])
@ -123,6 +121,14 @@ def test_default_config_keeps_frontmatter_chunk_metadata_opt_in():
) in (None, [])
def test_daily_cookbook_chunks_jsonl_one_line_at_a_time():
"""Daily cookbook keeps JSONL records as individually addressable chunks."""
cfg = _load_config("daily_cookbook.yaml")
jsonl = cfg["components"]["file_chunker"]["jsonl"]
assert jsonl["max_lines_per_chunk"] == 1
def test_parse_args_rejects_non_key_value_extra_argument():
"""Extra CLI arguments must use key=value syntax."""
with pytest.raises(ValueError, match="expected key=value"):

View file

@ -9,8 +9,10 @@ from unittest.mock import MagicMock
import pytest
from reme.components import ApplicationContext
from reme.components import ApplicationContext, R
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
from reme.config.config_parser import _load_config
from reme.enumeration import ComponentEnum
from reme.steps.cookbook.dingtalk.wait import DingTalkWaitStep, _session_key
@ -201,6 +203,54 @@ async def test_final_reply_injects_only_configured_tools(tmp_path):
]
def test_daily_cookbook_registers_one_step_background_wait_job(monkeypatch):
for name in ("DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"):
monkeypatch.delenv(name, raising=False)
config = _load_config("daily_cookbook")
job = config["jobs"]["dingtalk_wait"]
assert job["backend"] == "background"
assert job["steps"] == [
{
"backend": "dingtalk_wait_step",
"app_key": "",
"app_secret": "",
"robot_code": "",
"worker_count": 4,
"builtin_tools": ["bash"],
"job_tools": [
"memory_search",
"read",
"write",
"edit",
"daily_list",
"daily_write",
"frontmatter_read",
"frontmatter_update",
],
},
]
assert config["components"]["agent_wrapper"] == {
"default": {
"backend": "agentscope",
"as_llm": "default",
"builtin_tools": False,
},
}
assert R.get(ComponentEnum.STEP, "dingtalk_wait_step") is DingTalkWaitStep
def test_daily_cookbook_passes_dingtalk_environment_to_step(monkeypatch):
monkeypatch.setenv("DINGTALK_APP_KEY", "app-key")
monkeypatch.setenv("DINGTALK_APP_SECRET", "app-secret")
monkeypatch.setenv("DINGTALK_ROBOT_CODE", "robot-code")
step = _load_config("daily_cookbook")["jobs"]["dingtalk_wait"]["steps"][0]
assert (step["app_key"], step["app_secret"], step["robot_code"]) == (
"app-key",
"app-secret",
"robot-code",
)
@pytest.mark.asyncio
async def test_stream_client_closes_when_background_stop_is_set(monkeypatch):
websocket = _WebSocket()

View file

@ -1,16 +1,13 @@
"""HTTP service coverage for MCP and the optional bundled web workspace."""
"""HTTP service coverage for the optional bundled web workspace."""
import asyncio
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from reme.components.service.http_service import HttpService
from reme.components.job import BaseJob, StreamJob
from reme.utils import REME_WEB_STATIC_DIR, resolve_web_static_dir
@ -33,10 +30,7 @@ def _static_build(tmp_path: Path) -> Path:
static_dir = tmp_path / "web"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(static_dir / "index.html").write_text(
"<main>ReMe workspace</main>",
encoding="utf-8",
)
(static_dir / "index.html").write_text("<main>ReMe workspace</main>", encoding="utf-8")
(static_dir / "favicon.svg").write_text("<svg></svg>", encoding="utf-8")
(assets_dir / "app.js").write_text("console.log('reme')", encoding="utf-8")
return static_dir
@ -72,10 +66,7 @@ def test_http_service_serves_workspace_without_shadowing_jobs(tmp_path: Path) ->
def test_http_service_can_disable_workspace(tmp_path: Path) -> None:
"""Leave the root route unregistered when workspace serving is disabled."""
app = _FakeApplication()
service = HttpService(
web_enabled=False,
web_static_dir=str(_static_build(tmp_path)),
)
service = HttpService(web_enabled=False, web_static_dir=str(_static_build(tmp_path)))
service.build_service(app) # type: ignore[arg-type]
service.finalize_service(app) # type: ignore[arg-type]
@ -83,148 +74,7 @@ def test_http_service_can_disable_workspace(tmp_path: Path) -> None:
assert client.get("/").status_code == 404
def test_http_service_exposes_non_stream_jobs_as_mcp_tools() -> None:
"""The HTTP backend exposes the same non-stream Job instance through MCP."""
async def run() -> None:
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
job = BaseJob(name="search", description="Search memories")
assert service.add_job(job) is True
assert service.mcp_server is not None
assert await service.mcp_server.get_tool("search") is not None
assert any(route.path == "/search" for route in service.service.routes)
asyncio.run(run())
def test_http_service_skips_stream_jobs_for_mcp() -> None:
"""Stream jobs remain available over HTTP SSE without becoming MCP tools."""
async def run() -> None:
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
assert service.add_job(StreamJob(name="stream")) is True
assert service.mcp_server is not None
assert await service.mcp_server.get_tool("stream") is None
assert any(route.path == "/stream" for route in service.service.routes)
asyncio.run(run())
def test_http_service_uses_exact_mcp_path_and_runs_one_application_lifespan() -> None:
"""Serve MCP at /mcp while starting and closing the shared Application once."""
class CountingApplication(_FakeApplication):
"""Record how often the shared Application lifecycle is entered."""
def __init__(self) -> None:
super().__init__()
self.start_count = 0
self.close_count = 0
async def start(self) -> None:
self.start_count += 1
async def close(self) -> None:
self.close_count += 1
app = CountingApplication()
service = HttpService(web_enabled=False)
service.build_service(app) # type: ignore[arg-type]
mcp_server = service.mcp_server
class VerifyFastMCPAppMiddleware(BaseHTTPMiddleware):
"""Prove requests retain FastMCP middleware and application state."""
async def dispatch(self, request, call_next):
"""Verify the child app context and mark its response."""
assert request.app.state.fastmcp_server is mcp_server
response = await call_next(request)
response.headers["X-FastMCP-Middleware"] = "preserved"
return response
service.mcp_app.add_middleware(VerifyFastMCPAppMiddleware)
with TestClient(service.service, follow_redirects=False) as client:
response = client.post(
"/mcp",
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
},
)
assert response.status_code == 200
assert '"serverInfo"' in response.text
assert response.headers["X-FastMCP-Middleware"] == "preserved"
assert client.post("/mcp/mcp", json={}).status_code == 404
assert app.start_count == 1
assert app.close_count == 1
def test_http_service_can_disable_mcp() -> None:
"""Allow deployments to retain the legacy HTTP-only surface explicitly."""
service = HttpService(web_enabled=False, mcp_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.post("/mcp", json={}).status_code == 404
def test_http_service_rejects_invalid_or_conflicting_mcp_paths() -> None:
"""Reject paths that shadow built-ins and jobs that shadow the MCP endpoint."""
for path in (
"mcp",
"/",
"/mcp/",
"/docs",
"/{rest:path}",
"/mcp?mode=test",
"/mcp#fragment",
"/mcp%2Fv2",
"/mcp%20v2",
"/mcp%252Fv2",
"/mcp path",
"/mcp//nested",
"/mcp/../nested",
):
with pytest.raises(ValueError, match="mcp_path"):
HttpService(mcp_path=path)
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="conflicts with the MCP endpoint"):
service.add_job(BaseJob(name="mcp"))
def test_http_service_fails_startup_preflight_for_mcp_job_conflict() -> None:
"""Do not let BaseService's tolerant registration hide reserved-route conflicts."""
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
app = SimpleNamespace(
context=SimpleNamespace(jobs={"mcp": BaseJob(name="mcp")}),
)
with pytest.raises(ValueError, match="conflicts with the MCP endpoint"):
service.add_jobs(app)
def test_http_service_does_not_serve_symlinks_outside_static_dir(
tmp_path: Path,
) -> None:
def test_http_service_does_not_serve_symlinks_outside_static_dir(tmp_path: Path) -> None:
"""Do not expose files reached through symlinks outside the static build."""
static_dir = _static_build(tmp_path)
secret_file = tmp_path / "secret.txt"
@ -243,10 +93,7 @@ def test_http_service_does_not_serve_symlinks_outside_static_dir(
assert client.get("/escape.txt").text == "<main>ReMe workspace</main>"
def test_static_dir_configuration_precedes_environment(
monkeypatch,
tmp_path: Path,
) -> None:
def test_static_dir_configuration_precedes_environment(monkeypatch, tmp_path: Path) -> None:
"""Prefer an explicit static directory over the environment setting."""
configured = _static_build(tmp_path / "configured")
environment = _static_build(tmp_path / "environment")

View file

@ -65,31 +65,6 @@ def test_strip_injected_parameters_hides_keys_from_schema():
assert "date" in job.parameters["properties"]
def test_search_injection_exposes_only_query():
parameters = {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"min_score": {"type": "number"},
"start_date": {"type": "string"},
"end_date": {"type": "string"},
},
"required": ["query"],
}
injected = {
"limit": 20,
"min_score": 0.0,
"start_date": None,
"end_date": "2026-07-20",
}
stripped = BaseAgentWrapper._strip_injected_parameters(parameters, injected)
assert stripped["properties"] == {"query": {"type": "string"}}
assert stripped["required"] == ["query"]
# -- AgentScope wrapper -----------------------------------------------------------
@ -164,7 +139,7 @@ class _RecordingWrapper(BaseAgentWrapper):
super().__init__(**kwargs)
self.calls: list[dict] = []
async def reply(self, _inputs, **kwargs) -> dict:
async def reply(self, inputs, **kwargs) -> dict:
self.calls.append(kwargs)
return {"session_id": "s-1", "last_message": {}, "result": "ok"}

View file

@ -509,95 +509,24 @@ def test_cache_space_is_rechecked_after_async_load(monkeypatch, tmp_path):
run(go())
def test_whole_request_retries_after_vector_space_changes_between_batches():
"""Completed batches must be discarded when a later batch changes vector space."""
def test_completed_request_only_writes_to_its_active_cache_space():
"""A v3 request must not populate v4 after the provider switches back to v3."""
async def go():
embedding = FakeAsEmbedding()
embedding.vector_space_id = "v3"
store = LocalEmbeddingStore(name="t_local_write_race", max_batch_size=1, enable_cache=False)
embedding = OpenAIAsEmbedding(name="t_space_write_race", backend="openai", model="v3", dimensions=2)
store = LocalEmbeddingStore(name="t_local_write_race")
store.as_embedding = embedding
store._cache_space = embedding.vector_space_id
calls = 0
async def switch_during_second_batch(batch, **_kwargs):
nonlocal calls
calls += 1
if calls == 2:
embedding.vector_space_id = "v4"
idx, _text, key = batch[0]
version = 3.0 if calls < 3 else 4.0
return [(idx, key, np.array([version, 0.0], dtype=np.float16))]
async def compute_after_round_trip(_batch, **_kwargs):
embedding.model = FakeProviderModel("v4")
store._cache_space = embedding.vector_space_id
embedding.model = FakeProviderModel("v3")
return [(0, "key", np.array([3.0, 0.0], dtype=np.float16))]
store._compute_batch = switch_during_second_batch
results = await store.get_embeddings(["first", "second"])
store._compute_batch = compute_after_round_trip
await store._fill_misses([(0, "text", "key")], [None])
assert calls == 4
assert store._cache_space == embedding.vector_space_id
for result in results:
np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
run(go())
def test_whole_request_rereads_cache_after_vector_space_changes(monkeypatch, tmp_path):
"""A cache hit from the old space must not survive a later provider switch."""
async def go():
monkeypatch.setattr(
LocalEmbeddingStore,
"component_metadata_path",
property(lambda _self: tmp_path),
)
embedding = FakeAsEmbedding()
embedding.vector_space_id = "v3"
store = LocalEmbeddingStore(name="t_local_cache_race", enable_cache=True)
store.as_embedding = embedding
store._cache_space = embedding.vector_space_id
first_key = store._cache_key("first")
store._cache[first_key] = np.array([3.0, 0.0], dtype=np.float16)
calls = 0
async def switch_on_miss(batch, **_kwargs):
nonlocal calls
calls += 1
if calls == 1:
embedding.vector_space_id = "v4"
version = 3.0 if calls == 1 else 4.0
return [(idx, key, np.array([version, 0.0], dtype=np.float16)) for idx, _text, key in batch]
store._compute_batch = switch_on_miss
results = await store.get_embeddings(["first", "second"])
assert calls == 2
for result in results:
np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
run(go())
def test_whole_request_stops_retrying_when_vector_space_keeps_changing():
"""Continuous configuration churn must discard the whole request instead of blocking forever."""
async def go():
embedding = FakeAsEmbedding()
embedding.vector_space_id = "v3"
store = LocalEmbeddingStore(name="t_local_write_churn")
store.as_embedding = embedding
store._cache_space = embedding.vector_space_id
calls = 0
async def change_space_every_time(_batch, **_kwargs):
nonlocal calls
calls += 1
embedding.vector_space_id = f"v{calls + 3}"
return [(0, "key", np.array([float(calls), 0.0], dtype=np.float16))]
store._compute_batch = change_space_every_time
results = await store.get_embeddings(["text"])
assert calls == 3
assert results == [None]
assert "key" not in store._cache
run(go())

View file

@ -30,7 +30,6 @@ def test_logo_uses_runtime_http_address(monkeypatch) -> None:
output = _render_logo(monkeypatch, config, runtime_service)
assert "http://0.0.0.0:8123" in output
assert "http://0.0.0.0:8123/mcp" in output
def test_logo_fallback_matches_service_defaults(monkeypatch) -> None:
@ -40,18 +39,6 @@ def test_logo_fallback_matches_service_defaults(monkeypatch) -> None:
output = _render_logo(monkeypatch, config)
assert f"http://{REME_DEFAULT_HOST}:{REME_DEFAULT_PORT}" in output
assert f"http://{REME_DEFAULT_HOST}:{REME_DEFAULT_PORT}/mcp" in output
def test_logo_hides_disabled_http_mcp_endpoint(monkeypatch) -> None:
"""Do not advertise MCP when it is explicitly disabled on the HTTP service."""
config = ApplicationConfig(
service=ComponentConfig(backend="http", mcp_enabled=False),
)
output = _render_logo(monkeypatch, config)
assert f"http://{REME_DEFAULT_HOST}:{REME_DEFAULT_PORT}/mcp" not in output
def test_logo_uses_runtime_mcp_transport_and_address(monkeypatch) -> None:

View file

@ -7,7 +7,6 @@ import tomllib
from types import ModuleType
from packaging.requirements import Requirement
from packaging.version import Version
import pytest
REPOSITORY = Path(__file__).resolve().parents[2]
@ -42,13 +41,17 @@ def test_studio_packages_have_independent_identity() -> None:
assert studio_config["project"]["name"] == "reme_studio"
assert npm_config["name"] == "@agentscope-ai/reme_studio"
assert studio_config["project"]["version"] == npm_config["version"]
assert main_config["project"]["optional-dependencies"]["as"] == ["agentscope[model-ollama]==2.0.7"]
assert main_config["project"]["optional-dependencies"]["as"] == ["agentscope[model-ollama]==2.0.6"]
assert main_config["project"]["optional-dependencies"]["web"] == ["reme_studio"]
assert main_config["project"]["optional-dependencies"]["core"].count("reme-ai[as]") == 1
assert main_config["project"]["optional-dependencies"]["core"].count("reme_studio") == 1
assert "qwenpaw" not in main_config["project"]["optional-dependencies"]
assert auto_fin_config["project"]["version"] == "0.1.2"
assert daily_paper_config["project"]["version"] == "0.1.2"
assert main_config["project"]["optional-dependencies"]["qwenpaw"] == [
"reme-ai[core]",
"reme-auto-fin>=0.1.1",
"reme-daily-paper>=0.1.1",
]
assert auto_fin_config["project"]["version"] == "0.1.1"
assert daily_paper_config["project"]["version"] == "0.1.1"
assert main_config["tool"]["setuptools"]["packages"]["find"]["include"] == ["reme", "reme.*"]
assert "reme_studio*" in main_config["tool"]["setuptools"]["packages"]["find"]["exclude"]
@ -150,16 +153,14 @@ def test_auto_fin_license_matches_repository() -> None:
).read_text(encoding="utf-8")
def test_auto_fin_requires_reme_base() -> None:
"""Keep the plugin dependency limited to ReMe's public base package."""
def test_auto_fin_requires_reme_core() -> None:
"""Install the optional runtime packages needed while loading Auto Fin's entry points."""
config = tomllib.loads((REPOSITORY / "plugins" / "auto-fin" / "pyproject.toml").read_text(encoding="utf-8"))
requirements = [Requirement(value) for value in config["project"]["dependencies"]]
reme_requirements = [requirement for requirement in requirements if requirement.name == "reme-ai"]
assert len(reme_requirements) == 1
assert not reme_requirements[0].extras
assert Version("0.4.1.8") not in reme_requirements[0].specifier
assert Version("0.4.1.9") in reme_requirements[0].specifier
assert set(reme_requirements[0].extras) == {"core"}
def test_daily_paper_license_matches_repository() -> None:
@ -170,14 +171,12 @@ def test_daily_paper_license_matches_repository() -> None:
def test_daily_paper_declares_runtime_dependencies() -> None:
"""Keep Daily Paper's minimal ReMe and PDF dependencies explicit."""
"""Keep Daily Paper's ReMe feature set and PDF parser explicit in its own distribution."""
config = tomllib.loads((REPOSITORY / "plugins" / "daily_paper" / "pyproject.toml").read_text(encoding="utf-8"))
requirements = [Requirement(value) for value in config["project"]["dependencies"]]
by_name = {requirement.name: requirement for requirement in requirements}
assert not by_name["reme-ai"].extras
assert Version("0.4.1.8") not in by_name["reme-ai"].specifier
assert Version("0.4.1.9") in by_name["reme-ai"].specifier
assert set(by_name["reme-ai"].extras) == {"core"}
assert "pypdf" in by_name

View file

@ -76,7 +76,7 @@ def test_list_plugins_marks_configured_plugins(monkeypatch, tmp_path, capsys):
)
monkeypatch.setattr(plugin_cli_module, "_enabled_plugins", lambda _config: {"auto-fin"})
assert plugin_cli_module.plugin_cli(["list", "--config", "default"]) == 0
assert plugin_cli_module.plugin_cli(["list", "--config", "daily_cookbook"]) == 0
output = capsys.readouterr().out
assert "ENABLED" in output