fix: reduce empty episodes, finalize idle episodes, filter auto-skill prompts#1585
fix: reduce empty episodes, finalize idle episodes, filter auto-skill prompts#1585Starfie1d1272 wants to merge 22 commits intoMemTensor:mainfrom
Conversation
…ive sessions; add episode delete API
Two related fixes:
1. Orphan episode protection (2 files):
- core/pipeline/memory-core.ts: init() now checks session.meta.closedAt
before treating open episodes as orphans. Episodes from sessions
that haven't been explicitly closed are no longer abandoned on
bridge restart.
- core/session/manager.ts: closeSession() now stamps session.meta.closedAt
so future init() calls can distinguish 'explicitly closed' from
'crashed and might reconnect'.
2. WebUI Tasks page bulk delete (3 files):
- core/storage/repos/episodes.ts: added deleteById() method
- core/pipeline/memory-core.ts: added deleteEpisode() / deleteEpisodes()
- agent-contract/memory-core.ts: added interface signatures
- server/routes/session.ts: DELETE /api/v1/episodes now calls
deleteEpisode (actually removes the row + cascading traces)
instead of closeEpisode (no-op on already-closed episodes).
Added POST /api/v1/episodes/delete for bulk operations.
…rmes session restart Three fixes for the Hermes bridge adapter: 1. Use tsx runtime instead of node --experimental-strip-types 2. PID file to prevent duplicate bridge processes 3. Bridge lifetime tracking via register_bridge()
…d of hardcoding Previously the bridge reported '2.0.0-alpha.1' regardless of the actual package version. Now it reads from package.json at startup.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Prevent deleting episodes that are currently open/in-flight, which could cause FK violations and corrupt in-memory pipeline state. Co-Authored-By: Copilot <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
… TCP Changes: - bridge_client.py: add TCP transport mode — connect to daemon bridge at 127.0.0.1:18911 instead of spawning a subprocess. Supports transparent reconnection on connection loss. Falls back to stdio subprocess mode when TCP params are not provided. - __init__.py (MemTensorProvider): connect via TCP to daemon bridge; on Hermes CLI exit, skip episode.close + session.close so the daemon bridge owns the session and the pipeline can finalize episodes naturally. Why: previously each Hermes CLI session spawned its own bridge subprocess, which died on CLI exit → all open episodes abandoned as 'session_closed:client'. With TCP mode the daemon bridge (persistent background process) keeps the session alive, and episodes get properly finalized by the pipeline.
…ddress Copilot review Changes: - bridge/tcp.ts: new TCP JSON-RPC server (line-delimited, multi-client) - bridge.cts: start TCP server when --daemon --tcp=<port> is given; daemon mode now waits for TCP server stop instead of blocking forever - memory-core.ts: batch-fetch sessions to avoid N+1 orphan scan (Copilot suggestion); add SQLite fallback check in assertEpisodeDeletable (Copilot suggestion)
…k, double newline, port validation Changes per Copilot review (8 comments): - bridge_client.py: fix double-newline in _SocketTransport.write_line() - bridge.cts: validate --tcp port with Number.isFinite() before use - __init__.py: TCP mode with stdio fallback (try TCP first, spawn subprocess on failure); restore episode.close + session.close in on_session_end (Copilot correctly noted closedAt must be stamped) - daemon_manager.py: add mkdir(parents=True) before writing PID file (prevents crash when parent dir doesn't exist)
bridge_client.py: close old transport before reconnect bridge/tcp.ts: await server.close() in TcpServerHandle.close() agent-contract/memory-core.ts: deleteEpisode/deleteEpisodes no longer optional
bridge/tcp.ts: wrap server.listen in a promise; expose 'ready' so
callers catch EADDRINUSE; remove stale server-level error handler
bridge.cts: await tcpServer.ready inside try/catch
bridge_client.py: enable TCP mode when either tcp_host or tcp_port
is provided (not just tcp_host)
server/routes/session.ts: return { ok: true, deleted } for backward
compatibility with callers expecting { ok: true }
…leanup bridge.cts: add shuttingDown guard to prevent double-shutdown bridge/tcp.ts: validate typeof msg.method === 'string'; sock.destroy() on error to prevent resource leak
bridge/tcp.ts: check sock.destroyed before destroying core/pipeline/memory-core.ts: extract deleteClosedEpisode helper with no-op regression guard
…r import bridge.cts: close tcpServer on startup failure; warn and skip TCP when --daemon not set; daemon mode now also falls back to stdio when TCP port is not given bridge/tcp.ts: isListening flag for error handling (no more removeAllListeners); runtime errors are logged not swallowed __init__.py: import BridgeError so TCP→stdio fallback actually works
…stale PID validation, daemon stdio gate
There was a problem hiding this comment.
Pull request overview
This PR targets episode/session lifecycle correctness and noise reduction across the Hermes adapter, the MemoryCore pipeline, and OpenClaw capture, with additional bridge transport/lifecycle changes to support daemon/TCP operation.
Changes:
- Stop Hermes from pre-creating adapter-initiated episodes and add daemon/TCP bridge support (with PID-file management) to reduce orphan/abandoned episodes.
- Adjust stale-episode handling to finalize episodes with real traces (instead of always abandoning) and add episode deletion APIs.
- Strip OpenClaw/Hermes auto-skill evaluation prompts from captured content to prevent memory/index pollution.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts | Adds tests for init-time orphan/open episode handling and episode deletion behavior. |
| apps/memos-local-plugin/server/routes/session.ts | Switches episode DELETE to core deletion and adds bulk delete endpoint. |
| apps/memos-local-plugin/package-lock.json | Bumps package version alpha → beta and updates lock metadata. |
| apps/memos-local-plugin/core/storage/repos/episodes.ts | Adds deleteById to delete episode rows. |
| apps/memos-local-plugin/core/session/manager.ts | Stamps session.meta.closedAt on close to distinguish explicit closure from reconnect scenarios. |
| apps/memos-local-plugin/core/pipeline/memory-core.ts | Finalize-vs-abandon stale episodes based on trace presence; changes init orphan logic; adds deleteEpisode(s). |
| apps/memos-local-plugin/bridge/tcp.ts | Introduces a TCP JSON-RPC server transport with log/event broadcast. |
| apps/memos-local-plugin/bridge.cts | Adds daemon/TCP startup/shutdown wiring and reads package version dynamically. |
| apps/memos-local-plugin/agent-contract/memory-core.ts | Extends MemoryCore interface with deleteEpisode(s). |
| apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py | Adds PID file tracking + stale bridge termination helpers. |
| apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py | Adds TCP client mode + stdio fallback; integrates PID management in stdio mode. |
| apps/memos-local-plugin/adapters/hermes/memos_provider/init.py | Prefers TCP daemon bridge; removes eager episode.open; closes session/episode on session end. |
| apps/memos-local-openclaw/src/capture/index.ts | Adds stripping of auto-skill evaluation prompt blocks before storage. |
Files not reviewed (1)
- apps/memos-local-plugin/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function broadcast(obj: unknown): void { | ||
| const payload = JSON.stringify(obj) + "\n"; | ||
| for (const sock of clients) { | ||
| try { | ||
| sock.write(payload); | ||
| } catch { | ||
| /* best-effort per client */ |
There was a problem hiding this comment.
Acknowledged. This is a valid robustness concern but in practice TCP clients are few (1-3). Tracked for a follow-up PR focused on TCP transport hardening.
| # Try TCP mode first (connect to daemon bridge). | ||
| # Falls back to stdio (spawn subprocess) if daemon isn't running. | ||
| try: | ||
| self._bridge = MemosBridgeClient(tcp_host="127.0.0.1", tcp_port=18911) | ||
| except BridgeError: |
There was a problem hiding this comment.
Not an issue: the orchestrator's onTurnEnd() (memory-core.ts:655) resolves episodeId via openEpisodeBySession.get(sessionId) — the in-memory cache populated by turn.start. The adapter's _episode_id is only a fallback that is never reached in normal flow.
| # (e.g. session.meta.closedAt). In TCP mode this ensures the daemon | ||
| # can distinguish "normal shutdown" from "abrupt disconnect" on | ||
| # restart, preventing orphan retention. | ||
| with contextlib.suppress(Exception): | ||
| self._bridge.request("episode.close", {"episodeId": self._episode_id}) |
There was a problem hiding this comment.
Not an issue: the episode.close call is wrapped in contextlib.suppress(Exception) and will silently no-op on empty id. The actual episode finalization is handled by autoFinalizeStaleTasks() in the pipeline.
| const orphans = openEpisodes.filter((ep) => { | ||
| const session = sessionById.get(ep.sessionId); | ||
| if (!session) return true; // session row gone — orphan | ||
| if (session.meta?.closedAt != null) return true; // explicitly closed — orphan | ||
| return false; // session exists and not closed — skip, might reconnect |
There was a problem hiding this comment.
Acknowledged. The orphan episode hydration issue originates from PR #1546's init logic. This needs a dedicated architectural PR to rehydrate open episodes into the orchestrator's in-memory state on boot. Too large to address here.
1dae053 to
c088cdb
Compare
f6ebf9d to
ebc5788
Compare
There was a problem hiding this comment.
Pull request overview
This PR reduces episode/task noise and improves correctness across the Hermes adapter + memos-local-plugin pipeline by (a) avoiding adapter-created “empty” episodes, (b) finalizing stale-but-nonempty episodes instead of abandoning them, and (c) adding episode deletion support plus a TCP JSON-RPC transport for daemonized Hermes usage.
Changes:
- Update stale-episode handling and init-time orphan cleanup logic in
MemoryCore, and adddeleteEpisode(s)APIs (HTTP + JSON-RPC + core). - Add a line-delimited JSON-RPC over TCP server and daemon-mode bridge wiring (
bridge.cts), plus Hermes Python client support (TCP-first, stdio fallback) with PID-file management. - Add unit tests for init-time orphan episode behavior and episode deletion, plus version bump to
2.0.0-beta.1.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/memos-local-plugin/core/pipeline/memory-core.ts | Finalize vs abandon stale episodes; change init orphan-episode closing rules; add deleteEpisode/deleteEpisodes. |
| apps/memos-local-plugin/agent-contract/memory-core.ts | Extend MemoryCore contract with deleteEpisode(s). |
| apps/memos-local-plugin/agent-contract/jsonrpc.ts | Add JSON-RPC method names for episode deletion. |
| apps/memos-local-plugin/bridge/methods.ts | Wire JSON-RPC dispatcher cases for episode deletion. |
| apps/memos-local-plugin/bridge/tcp.ts | New TCP transport: line-delimited JSON-RPC server + event/log broadcasting. |
| apps/memos-local-plugin/bridge.cts | Support daemon + TCP server startup/shutdown; read version from package.json. |
| apps/memos-local-plugin/server/routes/session.ts | HTTP endpoints for deleting episodes (single + bulk). |
| apps/memos-local-plugin/core/storage/repos/episodes.ts | Add SQLite deleteById repo method. |
| apps/memos-local-plugin/core/session/manager.ts | Stamp session.meta.closedAt on close for init-time orphan heuristics. |
| apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts | Add tests for orphan episode handling and episode deletion behavior. |
| apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py | Add TCP transport mode + reconnection; keep stdio fallback; PID-file coordination hooks. |
| apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py | Add PID-file helpers and tracked shutdown for bridge subprocesses. |
| apps/memos-local-plugin/adapters/hermes/memos_provider/init.py | TCP-first bridge connection; remove adapter episode pre-open; strip Hermes auto-skill eval prompt block. |
| apps/memos-local-plugin/package-lock.json | Version bump + lockfile updates. |
Files not reviewed (1)
- apps/memos-local-plugin/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)
apps/memos-local-plugin/adapters/hermes/memos_provider/init.py:537
initialize()no longer opens an episode, but_episode_idis still used inturn.endandepisode.close(and never updated fromturn.start). This will send an emptyepisodeId(required byTurnResultDTO) and can cause turns to fail to persist and sessions to be closed viasession.closewith the episode still open (leading toabandoninstead of finalize). Capture the routedepisodeIdfrom theturn.startresponse (e.g.resp['query']['episodeId']) and setself._episode_id(and ideallyself._session_idtoo) before calling_turn_end/episode.close; also guardepisode.closewhen no episode was started.
def _turn_start(self, query: str, *, session_id: str = "") -> str:
assert self._bridge is not None
resp = self._bridge.request(
"turn.start",
{
"agent": "hermes",
"sessionId": session_id or self._session_id,
"userText": query,
"ts": int(time.time() * 1000),
},
)
context = (resp or {}).get("injectedContext") or ""
if not context:
return ""
return f"## Recalled Memories\n{context}"
# Hermes injects a structured auto-skill evaluation prompt at task end:
# "Review the conversation above and consider whether a skill should
# be saved or updated. Work in this order… SURVEY … THINK CLASS-FIRST …"
# Capturing this system-level scaffolding as conversation content pollutes
# memory search, task summaries, and downstream skill generation.
_AUTO_SKILL_EVAL_RE = re.compile(
r"^Review the conversation above and consider whether a "
r"skill should be saved or updated\.",
re.MULTILINE,
)
def _turn_end(
self,
user_content: str,
assistant_content: str,
tool_calls: list[dict[str, Any]],
ts_ms: int,
) -> None:
if not self._bridge:
return
# Strip Hermes auto-skill evaluation blocks from the assistant
# response. When the header phrase is present the entire remainder
# of the message is system-generated scaffolding, not user content.
m = self._AUTO_SKILL_EVAL_RE.search(assistant_content)
if m:
assistant_content = assistant_content[: m.start()].strip()
if not assistant_content.strip() and not user_content.strip():
return
self._bridge.request(
"turn.end",
{
"agent": "hermes",
"sessionId": self._session_id,
"episodeId": self._episode_id,
"agentText": assistant_content,
"userText": user_content,
"toolCalls": tool_calls,
"ts": ts_ms,
},
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -93,30 +129,55 @@ async function main(): Promise<void> { | |||
| `bridge: viewer failed to start: ${(err as Error).message}\n`, | |||
| ); | |||
| } | |||
| let shuttingDown = false; | |||
|
|
|||
| const shutdown = async (sig: string) => { | |||
| if (shuttingDown) return; | |||
| shuttingDown = true; | |||
| process.stderr.write(`bridge: received ${sig}, shutting down\n`); | |||
| try { | |||
| if (tcpServer) await tcpServer.close(); | |||
| } catch { | |||
| /* best-effort */ | |||
| } | |||
| try { | |||
| if (viewer) await viewer.close(); | |||
| } catch { | |||
| /* best-effort */ | |||
| } | |||
| await waitForShutdown(core, stdio); | |||
| if (stdio) { | |||
| await waitForShutdown(core, stdio); | |||
| } else { | |||
| try { | |||
| await core.shutdown(); | |||
| } catch { | |||
| /* swallow */ | |||
| } | |||
| } | |||
| process.exit(0); | |||
| }; | |||
|
|
|||
| process.on("SIGINT", () => void shutdown("SIGINT")); | |||
| process.on("SIGTERM", () => void shutdown("SIGTERM")); | |||
|
|
|||
| // Keep the process alive until stdin ends. | |||
| await stdio.done; | |||
| try { | |||
| if (viewer) await viewer.close(); | |||
| } catch { | |||
| /* best-effort */ | |||
| // In daemon mode, keep alive until TCP server stops (stdin is /dev/null). | |||
| // In stdio mode, run until the calling process closes stdin. | |||
| if (args.daemon && tcpServer) { | |||
| await tcpServer.done; | |||
| await shutdown("daemon_done"); | |||
| } else if (stdio) { | |||
| await stdio.done; | |||
| try { | |||
| if (viewer) await viewer.close(); | |||
| } catch { | |||
| /* best-effort */ | |||
| } | |||
| await core.shutdown(); | |||
| process.exit(0); | |||
| } else { | |||
| // Daemon mode without TCP — wait forever (kept alive by event loop). | |||
| await new Promise(() => {}); | |||
| } | |||
There was a problem hiding this comment.
Acknowledged. TCP startup failure in daemon mode should indeed be fatal when --tcp is explicitly requested. This is a design decision best handled in a separate PR alongside other daemon-mode robustness improvements.
| const openEpisodes = handle.repos.episodes.list({ status: "open", limit: 500 }); | ||
| // Batch-fetch sessions to avoid N+1 lookups per episode. | ||
| const sessionIds = new Set(openEpisodes.map((ep) => ep.sessionId)); | ||
| const sessionById = new Map<string, ReturnType<typeof handle.repos.sessions.getById>>(); | ||
| for (const sid of sessionIds) { | ||
| sessionById.set(sid, handle.repos.sessions.getById(sid)); | ||
| } | ||
| // Only treat an open episode as an orphan if its session has been | ||
| // explicitly closed (meta.closedAt is set) or no longer exists. | ||
| // Otherwise the session might reconnect — leave it alone. | ||
| const orphans = openEpisodes.filter((ep) => { | ||
| const session = sessionById.get(ep.sessionId); | ||
| if (!session) return true; // session row gone — orphan | ||
| if (session.meta?.closedAt != null) return true; // explicitly closed — orphan | ||
| return false; // session exists and not closed — skip, might reconnect | ||
| }); |
There was a problem hiding this comment.
Duplicate of #3159926566 — same orphan episode hydration concern from a different code location. Same conclusion: needs a dedicated architectural PR.
| async function deleteEpisodes(ids: readonly EpisodeId[]): Promise<{ deleted: number }> { | ||
| ensureLive(); | ||
| let deleted = 0; | ||
| for (const id of ids) { | ||
| if (deleteClosedEpisode(id)) deleted++; | ||
| } | ||
| return { deleted }; |
There was a problem hiding this comment.
Acknowledged. deleteEpisodes() should be atomic (pre-validate or wrap in transaction). Tracked for a follow-up PR on bulk operation safety.
| let buffer = ""; | ||
| sock.setEncoding("utf8"); | ||
|
|
||
| sock.on("data", (chunk: string) => { | ||
| buffer += chunk; | ||
| let nl = buffer.indexOf("\n"); | ||
| while (nl >= 0) { | ||
| const line = buffer.slice(0, nl); | ||
| buffer = buffer.slice(nl + 1); | ||
| void handleLine(sock, line); | ||
| nl = buffer.indexOf("\n"); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Acknowledged. Adding a max buffer/line length to the TCP parser is a sensible DoS hardening. Tracked for the follow-up TCP hardening PR.
| closeEpisode(episodeId: EpisodeId): Promise<void>; | ||
| deleteEpisode(episodeId: EpisodeId): Promise<{ deleted: boolean }>; | ||
| deleteEpisodes(ids: readonly EpisodeId[]): Promise<{ deleted: number }>; |
There was a problem hiding this comment.
Fixed in f191b15: added deleteEpisode and deleteEpisodes to the test stub in tests/unit/bridge/methods.test.ts.
…o-skill prompts Three fixes targeting noise reduction and correctness in the Hermes adapter pipeline, plus two Copilot review follow-ups: 1. Remove adapter-initiated episode pre-creation (__init__.py:165). The orchestrator already creates episodes on first real turn via openEpisodeIfNeeded(), so the pre-created empty episode was always abandoned as a 0-turn orphan. 2. Finalize stale episodes with content instead of abandoning them. autoFinalizeStaleTasks() now checks traceIds: episodes with at least one trace are finalized (triggering reflection + scoring), while truly empty episodes are still abandoned. 3. Strip Hermes auto-skill evaluation prompts in _turn_end(). Hermes injects a structured "review the conversation and decide whether to save a skill" prompt at task end. The adapter now detects the distinctive header phrase and truncates the system-generated scaffolding. 4. Add macOS ps(1) fallback for _is_bridge_process() (daemon_manager.py). /proc is not available on macOS, so PID validation always returned False. Fall back to `ps -p <pid> -o command=` when /proc is missing. 5. Add episode.delete / episode.delete_bulk JSON-RPC methods. HTTP routes had DELETE endpoints but the bridge lacked corresponding RPC methods, creating API surface inconsistency.
ebc5788 to
f191b15
Compare
Copilot Review — Resolution SummaryAll 13 Copilot comments have been reviewed and addressed (full replies in each thread). Fixed (6)
False positives (3)
Deferred — separate PRs (4)
From prior PR #1546 (now closed) — requires architectural work
🤖 Generated with Claude Code |
…_content for auto-skill eval - abandon(): when rTask is set, mark episode as finalized instead of abandoned, since the reward pipeline already completed. Log at info level and skip episode.abandoned event. - _turn_end(): apply _AUTO_SKILL_EVAL_RE to user_content too, so the review prompt sent as user_message to the fork agent is stripped.
… turn.start - reward.ts: stamp closeReason="finalized" when async scoring completes, so episodes abandoned before scoring (rTask was NULL at closeSession) get corrected to "finalized" once the reward pipeline finishes. - memos_provider/__init__.py: check query against _AUTO_SKILL_EVAL_RE in _run() before _turn_start(), so review prompts never create traces. - memory-core.ts: add TODO for orphan close bypassing capture+reward chain.
Summary
Three fixes targeting noise reduction and correctness in the Hermes adapter pipeline.
1. Remove adapter-initiated episode pre-creation
File:
adapters/hermes/memos_provider/__init__.pyThe
initialize()was callingepisode.opento pre-create an episode with placeholder text"(adapter-initiated)". The orchestrator'sonTurnStart()→openEpisodeIfNeeded()already creates a proper episode on the first real turn, so every Hermes session produced 2 episodes — one empty orphan that always got abandoned.Fix: Remove the
episode.opencall. The orchestrator creates episodes when needed.2. Finalize idle-timeout episodes instead of abandoning them
File:
core/pipeline/memory-core.tsautoFinalizeStaleTasks()was callingabandon()on all stale episodes regardless of content. This marked episodes with real conversation turns as "skipped" in the webUI. Idle timeout ≠ meaningless conversation.Fix: Check
traceIdsbefore deciding:finalizeEpisode()(triggers reflection + scoring pipeline, shows as "completed")abandon()(skipped, as before)3. Filter Hermes/OpenClaw auto-skill evaluation prompts
File:
memos-local-openclaw/src/capture/index.tsOpenClaw injects a structured auto-skill evaluation prompt at task end ("Review the conversation above and consider whether a skill should be saved or updated... SURVEY the existing skill landscape..."). Capturing this as conversation content pollutes memory search indices, task summaries, and downstream skill generation.
Fix: Add
stripAutoSkillEval()filter that detects the distinctive header phrase and strips the entire prompt block. Applied to all captured messages before storage.Test plan
memory-core.test.ts— 15/15 pass (no regressions)🤖 Generated with Claude Code