Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5b342f8
feat(api): align OpenMem v1 SDK with cloud API
Zyntrael Jul 8, 2026
57f0919
Merge branch 'MemTensor:main' into dev-v2.0.23-sdk
Zyntrael Jul 8, 2026
36f6849
Merge branch 'MemTensor:main' into dev-v2.0.23-sdk
Zyntrael Jul 14, 2026
f46c02e
feat(api): align OpenMem v1 SDK with cloud API
Zyntrael Jul 14, 2026
39643a2
Merge remote-tracking branch '我的/dev-v2.0.23-sdk' into dev-v2.0.23-sdk
Zyntrael Jul 14, 2026
c9745f6
align OpenMem v1 SDK with cloud API
bittergreen Jul 14, 2026
9d2c9b7
fix(memos-local): include json hint in user messages (#1756)
de1tydev Jul 14, 2026
a000a1c
docs: fix scheduler API examples to match server endpoints (#2113)
shinetata Jul 16, 2026
2c60267
fix(api): show structured add example in /docs for /product/add (#2112)
shinetata Jul 16, 2026
c0f6c2c
fix(memos-local-plugin): revert half-merged MemosHttpClient usages (#…
Memtensor-AI Jul 16, 2026
698c30f
fix: configure logging once per process
bittergreen Jul 16, 2026
2017cd5
fix: close scheduler resources on API shutdown
bittergreen Jul 16, 2026
8aefda7
fix: configure logging once per process
bittergreen Jul 16, 2026
9c7dd2f
fix: close scheduler resources via API lifespan
bittergreen Jul 16, 2026
61c1322
fix: stabilize logging and RabbitMQ shutdown lifecycle (#2117)
bittergreen Jul 16, 2026
e072676
feat(api):update SDK version number
Zyntrael Jul 17, 2026
e8b6690
feat(api):update SDK version number (#2118)
bittergreen Jul 17, 2026
d0802b6
chore: update version to v2.0.24
Jul 19, 2026
d9130a7
feat: chunk batch reflection scoring (#1957)
chiefmojo Jul 23, 2026
df41fe7
Revert "feat: chunk batch reflection scoring (#1957)" (#2145)
syzsunshine219 Jul 23, 2026
90c53de
fix: ensure gateway restarts on install failure via ERR trap
Zhe-SH-CN Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/memos-local-openclaw/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ NODE
info "Stop OpenClaw Gateway, 停止 OpenClaw Gateway..."
"${OPENCLAW_BIN}" gateway stop >/dev/null 2>&1 || true

# Ensure the gateway is restarted if the install fails after this point.
# The trap is cleared once the normal start step at the end succeeds.
trap '"${OPENCLAW_BIN}" gateway start >/dev/null 2>&1 || true' ERR

if command -v lsof >/dev/null 2>&1; then
PIDS="$(lsof -i :"${PORT}" -t 2>/dev/null || true)"
if [[ -n "$PIDS" ]]; then
Expand Down Expand Up @@ -406,6 +410,8 @@ info "Install OpenClaw Gateway service, 安装 OpenClaw Gateway 服务..."

success "Start OpenClaw Gateway service, 启动 OpenClaw Gateway 服务..."
"${OPENCLAW_BIN}" gateway start 2>&1
# Gateway is back up - clear the error recovery trap.
trap - ERR

info "Starting Memory Viewer, 正在启动记忆面板..."
VIEWER_URL="http://127.0.0.1:18799"
Expand Down
66 changes: 12 additions & 54 deletions apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,11 @@
if str(_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_PLUGIN_DIR))

from bridge_client import BridgeError, MemosBridgeClient, MemosHttpClient # noqa: E402
from bridge_client import BridgeError, MemosBridgeClient # noqa: E402
from daemon_manager import ( # noqa: E402
ensure_bridge_running,
ensure_viewer_daemon,
kill_zombie_bridges,
probe_viewer_status,
startup_lock_active,
)


Expand Down Expand Up @@ -279,7 +277,7 @@ class MemTensorProvider(MemoryProvider):
"""

def __init__(self) -> None:
self._bridge: MemosBridgeClient | MemosHttpClient | None = None
self._bridge: MemosBridgeClient | None = None
self._reconnect_lock = threading.Lock()
self._session_id: str = ""
self._episode_id: str = ""
Expand Down Expand Up @@ -329,23 +327,6 @@ def is_available(self) -> bool: # type: ignore[override]

# ─── Lifecycle ────────────────────────────────────────────────────────

def _connect_http_bridge(self, session_id: str, *, timeout: float = 60.0) -> bool:
"""Try to connect via HTTP bridge. Sets self._bridge on success."""
http_bridge: MemosHttpClient | None = None
try:
http_bridge = MemosHttpClient()
http_bridge.register_host_handler("host.llm.complete", self._handle_host_llm_complete)
self._bridge = http_bridge
self._open_session(session_id, timeout=timeout)
return True
except Exception as err:
logger.warning("MemOS: HTTP bridge failed, falling back to stdio — %s", err)
if http_bridge is not None:
with contextlib.suppress(Exception):
http_bridge.close()
self._bridge = None
return False

def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[override]
"""Called once at agent startup.

Expand Down Expand Up @@ -414,32 +395,13 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov
except Exception:
pass

# If the daemon is already running on the viewer port, connect
# to it over HTTP instead of spawning a new stdio bridge. This
# eliminates zombie bridge accumulation.
viewer_status = probe_viewer_status()
if viewer_status == "running_memos":
if self._connect_http_bridge(session_id):
logger.info(
"MemOS: bridge ready (HTTP) session=%s platform=%s (episode deferred)",
self._session_id,
self._platform,
)
else:
viewer_status = "free" # force stdio fallback below
elif viewer_status == "free":
# Re-probe after a short wait only when another process may be
# mid-startup (startup lock is held). On a cold first-launch the
# lock doesn't exist, so we skip the delay entirely.
if startup_lock_active():
time.sleep(1.0)
viewer_status = probe_viewer_status()
if viewer_status == "running_memos" and self._connect_http_bridge(session_id):
logger.info(
"MemOS: bridge ready (HTTP, late probe) session=%s platform=%s (episode deferred)",
self._session_id,
self._platform,
)
# NOTE: An HTTP bridge path used to live here that connected to a
# running viewer daemon over HTTP instead of spawning a stdio
# subprocess. It depended on ``MemosHttpClient`` which was never
# committed — the class was referenced by name only. Issue #2096
# reverts the half-merged HTTP feature; the stdio path below is
# the sole connection mechanism until the HTTP client lands as a
# complete change.

if self._bridge is None:
try:
Expand Down Expand Up @@ -1958,13 +1920,9 @@ def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> N
logger.info("MemOS: old bridge closed (pid=%s)", old_pid)

ensure_bridge_running()
# Try HTTP first if daemon is running
viewer_status = probe_viewer_status()
if viewer_status == "running_memos" and self._connect_http_bridge(
session_id, timeout=timeout
):
logger.info("MemOS: reconnected via HTTP")
return
# NOTE: HTTP bridge reconnect path was removed alongside issue
# #2096. See ``initialize`` for the rationale. Reconnect always
# spawns a fresh stdio bridge.

try:
ensure_viewer_daemon()
Expand Down
19 changes: 17 additions & 2 deletions apps/memos-local-plugin/core/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ export function createLlmClientWithProvider(
return [{ role: "system", content: systemInsert }, ...messages];
}

function ensureJsonWordInUserMessage(messages: LlmMessage[]): LlmMessage[] {
const lastUserIdx = messages.map((m) => m.role).lastIndexOf("user");
if (lastUserIdx < 0) return [...messages, { role: "user", content: "Return valid json only." }];

const msg = messages[lastUserIdx];
if (/\bjson\b/i.test(msg.content)) return messages;

const out = messages.slice();
out[lastUserIdx] = {
...msg,
content: `${msg.content}\n\nReturn valid json only.`,
};
return out;
}

function buildCallInput(opts: LlmCallOptions | undefined, jsonMode: boolean): ProviderCallInput {
return {
temperature: opts?.temperature ?? config.temperature,
Expand Down Expand Up @@ -463,7 +478,7 @@ export function createLlmClientWithProvider(
): Promise<LlmCompletion> {
const messages = normalizeMessages(input);
const msgsWithJsonHint = opts?.jsonMode
? inject(messages, buildJsonSystemHint())
? ensureJsonWordInUserMessage(inject(messages, buildJsonSystemHint()))
: messages;
const call = buildCallInput(opts, opts?.jsonMode === true);
const { completion } = await callWithFallback(msgsWithJsonHint, call, opts, opts?.op ?? "complete");
Expand All @@ -476,7 +491,7 @@ export function createLlmClientWithProvider(
): Promise<LlmJsonCompletion<T>> {
const messages = normalizeMessages(input);
const systemHint = buildJsonSystemHint(opts.schemaHint);
const msgs = inject(messages, systemHint);
const msgs = ensureJsonWordInUserMessage(inject(messages, systemHint));
const call = buildCallInput(opts, true);
const op = opts.op ?? "complete.json";
const maxMalformedRetries = Math.max(0, opts.malformedRetries ?? 1);
Expand Down
19 changes: 19 additions & 0 deletions apps/memos-local-plugin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,18 @@ wait_for_viewer() {
return 1
}

# ─── Gateway recovery helper ──────────────────────────────────────────────
# If the install fails after stopping the OpenClaw gateway (set -e / die),
# this trap ensures the gateway is restarted so the user is not left with
# a downed service.
_gateway_recovery() {
local oc_bin
oc_bin="$(find_openclaw_cli 2>/dev/null || true)"
if [[ -n "${oc_bin}" ]]; then
"${oc_bin}" gateway start >/dev/null 2>&1 || true
fi
}

# ─── OpenClaw install ─────────────────────────────────────────────────────
install_openclaw() {
STEP_CURRENT=0
Expand All @@ -429,11 +441,16 @@ install_openclaw() {
mkdir -p "${HOME}/.openclaw"

local oc_bin=""
local _gateway_was_stopped="false"
if oc_bin="$(find_openclaw_cli)"; then
step "Stopping OpenClaw gateway"
"${oc_bin}" gateway stop >/dev/null 2>&1 || true
sleep 1
success "Gateway stopped"
_gateway_was_stopped="true"
# Ensure the gateway is restarted if the install fails after this point.
# The trap is cleared once the normal start step at the end succeeds.
trap '_gateway_recovery' ERR
fi

deploy_tarball_to_prefix "${prefix}"
Expand Down Expand Up @@ -616,6 +633,8 @@ NODE
else
success "OpenClaw gateway started"
fi
# Gateway is back up — clear the error recovery trap.
trap - ERR

step "Waiting for Memory Viewer"
if wait_for_viewer "${OPENCLAW_PORT}"; then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,51 @@ def request(self, method: str, params: dict | None = None, **_kwargs: object) ->


class HermesProviderPipelineTests(unittest.TestCase):
def test_module_imports_cleanly(self) -> None:
"""Regression guard for #2096: asserts that ``MemosHttpClient`` is
NOT present in ``memos_provider``, since the class was referenced
before it was ever committed (see issue #2096).

Note: the import itself is already validated at collection time —
the ``import memos_provider`` at the top of this file will raise
``ImportError`` if a dangling reference is reintroduced, causing
the entire test file to fail to load. This test body only adds:

* the explicit negative guard on ``MemosHttpClient`` below (unique
to this test), which covers both the ``memos_provider``
re-export surface *and* ``bridge_client`` itself so a partial
re-add of the class only in ``bridge_client`` (with no
matching re-export) still fails the guard, and
* positive checks on ``MemTensorProvider`` (the class the Hermes
host actually instantiates) and on ``bridge_client``'s real
contract (``MemosBridgeClient`` / ``BridgeError``), rather than
on their incidental re-exports through ``memos_provider`` — the
latter only appear on the package namespace because
``__init__.py`` uses a bare ``from bridge_client import ...``,
which is an implementation detail we don't want the test to
lock in.
"""
import importlib

# MemTensorProvider is the class hermes-agent host instantiates.
self.assertTrue(hasattr(memos_provider, "MemTensorProvider"))

# Assert the actual contract on bridge_client directly rather
# than on its re-exports through memos_provider.
bc = importlib.import_module("bridge_client")
self.assertTrue(hasattr(bc, "MemosBridgeClient"))
self.assertTrue(hasattr(bc, "BridgeError"))

# ``MemosHttpClient`` was referenced by name in a half-merged HTTP
# bridge feature (see #2096). It must not reappear until the class
# itself is committed in ``bridge_client``. Guard both the
# ``memos_provider`` re-export (which is what the original
# ImportError travelled through) and ``bridge_client`` itself —
# otherwise a partial re-add of the class in ``bridge_client``
# without a matching re-export would slip past this test.
self.assertFalse(hasattr(memos_provider, "MemosHttpClient"))
self.assertFalse(hasattr(bc, "MemosHttpClient"))

def test_lifecycle_persists_turn_and_closes_real_episode(self) -> None:
bridge = FakeBridge()
with (
Expand Down
8 changes: 6 additions & 2 deletions apps/memos-local-plugin/tests/unit/llm/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,14 @@ describe("llm/client", () => {
expect(fake.lastMessages).toEqual([{ role: "user", content: "hi there" }]);
});

it("injects a json system hint when jsonMode=true", async () => {
it("injects json hints into system and user messages when jsonMode=true", async () => {
const fake = new FakeProvider("openai_compatible", () => ({ text: '{"ok":1}', durationMs: 1 }));
const client = createLlmClientWithProvider(cfg(), fake);
await client.complete("do it", { jsonMode: true });
expect(fake.lastMessages?.[0]?.role).toBe("system");
expect(fake.lastMessages?.[0]?.content).toMatch(/single valid JSON value/i);
expect(fake.lastMessages?.at(-1)?.role).toBe("user");
expect(fake.lastMessages?.at(-1)?.content).toMatch(/valid json only/i);
expect(fake.lastInput?.jsonMode).toBe(true);
});

Expand Down Expand Up @@ -270,7 +272,9 @@ describe("llm/client", () => {
expect(fake.lastMessages?.[0]?.role).toBe("system");
expect(fake.lastMessages?.[0]?.content).toMatch(/You are strict\./);
expect(fake.lastMessages?.[0]?.content).toMatch(/single valid JSON value/);
expect(fake.lastMessages?.[1]).toEqual({ role: "user", content: "go" });
expect(fake.lastMessages?.[1]?.role).toBe("user");
expect(fake.lastMessages?.[1]?.content).toMatch(/^go/);
expect(fake.lastMessages?.[1]?.content).toMatch(/valid json only/i);
});

it("rejects empty messages array", async () => {
Expand Down
48 changes: 31 additions & 17 deletions docs/cn/open_source/open_source_api/scheduler/get_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,34 +64,48 @@ desc: 监控 MemOS 异步任务的生命周期,提供包括任务进度、队

## 4. 快速上手示例

使用 SDK 轮询任务状态直至完成
这些接口由开源版 Server(`server_api`,路由前缀 `/product`)直接提供,使用标准 HTTP 请求即可访问。以下示例轮询任务状态直至完成

```python
from memos.api.client import MemOSClient
import time

client = MemOSClient(api_key="...", base_url="...")
import requests

# 自部署 MemOS Server 的地址(如启用了鉴权,请自行补充 Authorization 请求头)
base_url = "http://localhost:8000"

# 1. 系统级概览:查看整个 MemOS 系统的运行健康度
global_res = client.get_all_scheduler_status()
if global_res:
print(f"系统运行概况: {global_res.data['scheduler_summary']}")
resp = requests.get(f"{base_url}/product/scheduler/allstatus", timeout=10)
resp.raise_for_status()
global_res = resp.json()
print(f"系统运行概况: {global_res['data']['scheduler_summary']}")

# 2. 队列指标监控:检查特定用户的任务积压情况
queue_res = client.get_task_queue_status(user_id="dev_user_01")
if queue_res:
print(f"待处理任务数: {queue_res.data['remaining_tasks_count']}")
print(f"已下发未完成任务数: {queue_res.data['pending_tasks_count']}")
resp = requests.get(
f"{base_url}/product/scheduler/task_queue_status",
params={"user_id": "dev_user_01"},
timeout=10,
)
resp.raise_for_status()
queue_res = resp.json()
print(f"排队中任务数: {queue_res['data']['remaining_tasks_count']}")
print(f"已下发未确认任务数: {queue_res['data']['pending_tasks_count']}")

# 3. 任务进度追踪:轮询特定任务直至结束
task_id = "task_888999"
active_states = {"waiting", "pending", "in_progress"}
while True:
res = client.get_task_status(user_id="dev_user_01", task_id=task_id)
if res and res.code == 200:
current_status = res.data[0]['status'] # data 为状态列表
print(f"任务 {task_id} 当前状态: {current_status}")

if current_status in ['completed', 'failed', 'cancelled']:
break
resp = requests.get(
f"{base_url}/product/scheduler/status",
params={"user_id": "dev_user_01", "task_id": task_id},
timeout=10,
)
resp.raise_for_status()
items = resp.json().get("data", []) # data 为状态列表:[{"task_id": ..., "status": ...}]
statuses = {item["status"] for item in items}
print(f"任务 {task_id} 当前状态: {statuses or '空'}")

if not statuses or statuses.isdisjoint(active_states):
break
time.sleep(2)
```
Loading