From 17ec0c8bdc3935ac06222fa55a9a03ea7debb824 Mon Sep 17 00:00:00 2001 From: SR Date: Sun, 17 May 2026 15:08:06 -0600 Subject: [PATCH] fix(memos-local-plugin): harden Hermes install and startup recovery --- .../adapters/hermes/README.md | 8 +++ .../adapters/hermes/install.hermes.sh | 26 +++++---- .../core/pipeline/memory-core.ts | 19 ++++++- apps/memos-local-plugin/install.ps1 | 25 ++++++--- apps/memos-local-plugin/install.sh | 29 +++++++--- .../tests/unit/install/install-sh.test.ts | 13 +++++ .../tests/unit/pipeline/memory-core.test.ts | 54 ++++++++++++++++--- 7 files changed, 138 insertions(+), 36 deletions(-) diff --git a/apps/memos-local-plugin/adapters/hermes/README.md b/apps/memos-local-plugin/adapters/hermes/README.md index 1bd98cd0a..6f7c995f7 100644 --- a/apps/memos-local-plugin/adapters/hermes/README.md +++ b/apps/memos-local-plugin/adapters/hermes/README.md @@ -66,6 +66,14 @@ communicates over its stdin/stdout pipes. The subprocess exits when the provider's stdin closes (on `shutdown()`), yielding clean lifecycle semantics. +## Provider install path + +The installer links the provider into `~/.hermes/plugins/memtensor`, which is +owned by the user profile and survives Hermes source-tree upgrades. When an +older Hermes checkout exposes `~/.hermes/hermes-agent/plugins/memory`, the +installer also writes a compatibility link there so current and older provider +discovery both keep working. + ## Why a subprocess instead of a long-lived daemon? Earlier prototypes used a persistent HTTP daemon on a well-known port. diff --git a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh index 4d431c792..ccd92c8f0 100755 --- a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh +++ b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh @@ -8,9 +8,9 @@ # 1. Install node_modules inside $PREFIX (idempotent). # 2. Build the viewer bundle so the HTTP server has static assets # available. -# 3. Symlink the Python memos_provider package into the Hermes -# plugins directory so `from memos_provider import MemTensorProvider` -# resolves from Hermes without extra path munging. +# 3. Symlink the Python memos_provider package into Hermes' durable +# user-plugin directory, with an optional legacy source-tree link +# when HERMES_PLUGINS_DIR is supplied. # # We never modify the Hermes host process — its plugin manager picks # up $PREFIX on next start. @@ -48,16 +48,20 @@ else fi # ── 3. wire up Python provider ──────────────────────────────────────────────── -# Hermes discovers providers from $PREFIX. Symlinking the Python package -# to a discoverable path (if HERMES_PLUGINS_DIR is set) avoids asking the -# user to edit PYTHONPATH manually. +# Hermes upgrades can replace ~/.hermes/hermes-agent, so the primary link lives +# in the user-owned plugin directory. The legacy source-tree link is still +# written when the caller exposes it for older Hermes builds. +provider_source="$PREFIX/adapters/hermes/memos_provider" +user_plugins_dir="${HERMES_USER_PLUGINS_DIR:-$HOME/.hermes/plugins}" +mkdir -p "$user_plugins_dir" +ln -sfn "$provider_source" "$user_plugins_dir/memtensor" +cp "$PREFIX/adapters/hermes/plugin.yaml" "$provider_source/plugin.yaml" 2>/dev/null || true +log "Linked durable Python provider → $user_plugins_dir/memtensor" + if [[ -n "${HERMES_PLUGINS_DIR:-}" ]]; then mkdir -p "$HERMES_PLUGINS_DIR" - ln -sfn "$PREFIX/adapters/hermes/memos_provider" "$HERMES_PLUGINS_DIR/memos_provider" - log "Linked Python provider → $HERMES_PLUGINS_DIR/memos_provider" -else - log "HERMES_PLUGINS_DIR not set; skipping Python provider symlink. You can manually link:" - log " ln -s \"$PREFIX/adapters/hermes/memos_provider\" /memos_provider" + ln -sfn "$provider_source" "$HERMES_PLUGINS_DIR/memtensor" + log "Linked legacy Python provider → $HERMES_PLUGINS_DIR/memtensor" fi log "Hermes adapter install complete." diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 042a0deb7..3bbdb03a5 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -538,6 +538,7 @@ export function createMemoryCore( ); let lastStaleScan = 0; let lastDirtyClosedScan = 0; + let startupDirtyRecoveryScheduled = false; async function autoFinalizeStaleTasks(): Promise { const nowMs = Date.now(); if (nowMs - lastStaleScan < 30_000) return; @@ -584,6 +585,22 @@ export function createMemoryCore( } } + function scheduleDirtyClosedRecovery( + episodes: Array }>, + ): void { + if (startupDirtyRecoveryScheduled) return; + startupDirtyRecoveryScheduled = true; + log.info("init.dirty_closed_episodes.rescore_scheduled", { count: episodes.length }); + setTimeout(() => { + if (shutDown) return; + void recoverDirtyClosedEpisodes(episodes).catch((err) => { + log.debug("dirty_closed_reward.startup_recovery_failed", { + err: err instanceof Error ? err.message : String(err), + }); + }); + }, 0); + } + // ─── Lifecycle ── async function init(): Promise { if (shutDown) { @@ -624,7 +641,7 @@ export function createMemoryCore( .list({ status: "closed", limit: 500 }) .filter((ep) => episodeRewardIsDirty(ep)); if (dirtyClosed.length > 0) { - await recoverDirtyClosedEpisodes(dirtyClosed); + scheduleDirtyClosedRecovery(dirtyClosed); } } catch (err) { log.debug("init.orphan_scan.failed", { diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index 79efa5e4f..3a2953c42 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -417,14 +417,23 @@ function Install-Hermes { } catch {} } - if (-not $PluginDir -or -not (Test-Path $PluginDir)) { Stop-Die "plugins\memory not found" } - - $Target = Join-Path $PluginDir "memtensor" - if (Test-Path $Target) { Remove-Item -Recurse -Force $Target } - - New-Item -ItemType Junction -Path $Target -Value (Join-Path $AdapterDir "memos_provider") | Out-Null - Copy-Item -Path (Join-Path $AdapterDir "plugin.yaml") -Destination (Join-Path $AdapterDir "memos_provider\plugin.yaml") -ErrorAction SilentlyContinue - Write-Success "Linked -> $Target" + $ProviderSource = Join-Path $AdapterDir "memos_provider" + $UserPluginRoot = Join-Path $env:LOCALAPPDATA "hermes\plugins" + $UserTarget = Join-Path $UserPluginRoot "memtensor" + New-Item -ItemType Directory -Path $UserPluginRoot -Force | Out-Null + if (Test-Path $UserTarget) { Remove-Item -Recurse -Force $UserTarget } + New-Item -ItemType Junction -Path $UserTarget -Value $ProviderSource | Out-Null + Copy-Item -Path (Join-Path $AdapterDir "plugin.yaml") -Destination (Join-Path $ProviderSource "plugin.yaml") -ErrorAction SilentlyContinue + Write-Success "Linked durable provider -> $UserTarget" + + if ($PluginDir -and (Test-Path $PluginDir)) { + $Target = Join-Path $PluginDir "memtensor" + if (Test-Path $Target) { Remove-Item -Recurse -Force $Target } + New-Item -ItemType Junction -Path $Target -Value $ProviderSource | Out-Null + Write-Success "Linked legacy source-tree provider -> $Target" + } else { + Write-Warning "Hermes source-tree plugins\memory not found; durable user-plugin link was still installed." + } if (Test-Path $ConfigFile) { $PyScript = @" diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index 75cb7c8cd..5165c556a 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -705,17 +705,30 @@ except Exception: [[ -d "${d}" && -f "${d}/__init__.py" ]] && { plugin_dir="${d}"; break; } done fi - [[ -n "${plugin_dir}" && -d "${plugin_dir}" ]] || die "plugins/memory not found" - success "plugins/memory: ${plugin_dir}" step "Linking memtensor provider" - local target="${plugin_dir}/memtensor" - if [[ -L "${target}" ]]; then rm "${target}" - elif [[ -e "${target}" ]]; then rm -rf "${target}" + local provider_source="${adapter_dir}/memos_provider" + local user_plugins_dir="${HOME}/.hermes/plugins" + local user_target="${user_plugins_dir}/memtensor" + mkdir -p "${user_plugins_dir}" + if [[ -L "${user_target}" ]]; then rm "${user_target}" + elif [[ -e "${user_target}" ]]; then rm -rf "${user_target}" + fi + ln -s "${provider_source}" "${user_target}" + cp "${adapter_dir}/plugin.yaml" "${provider_source}/plugin.yaml" 2>/dev/null || true + success "Linked durable provider → ${user_target}" + + if [[ -n "${plugin_dir}" && -d "${plugin_dir}" ]]; then + success "plugins/memory: ${plugin_dir}" + local target="${plugin_dir}/memtensor" + if [[ -L "${target}" ]]; then rm "${target}" + elif [[ -e "${target}" ]]; then rm -rf "${target}" + fi + ln -s "${provider_source}" "${target}" + success "Linked legacy source-tree provider → ${target}" + else + warn "Hermes source-tree plugins/memory not found; durable user-plugin link was still installed." fi - ln -s "${adapter_dir}/memos_provider" "${target}" - cp "${adapter_dir}/plugin.yaml" "${adapter_dir}/memos_provider/plugin.yaml" 2>/dev/null || true - success "Symlinked → ${target}" step "Verifying provider & patching config" local verify diff --git a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts index 0ddf1f9d2..5f0d0b056 100644 --- a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts @@ -22,6 +22,7 @@ import { readFileSync } from "node:fs"; const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const SCRIPT = path.join(REPO_ROOT, "install.sh"); +const WINDOWS_SCRIPT = path.join(REPO_ROOT, "install.ps1"); const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); function run(args: string[], env: Record = {}) { @@ -102,4 +103,16 @@ describe("install.sh — CLI surface", () => { expect(pkg.files).not.toContain("docs"); expect(pkg.files).not.toContain("tests"); }); + + it("installs Hermes provider into the durable user-plugin directory", () => { + const script = readFileSync(SCRIPT, "utf8"); + expect(script).toContain('${HOME}/.hermes/plugins'); + expect(script).toContain('Linked durable provider'); + expect(script).toContain('Linked legacy source-tree provider'); + + const windowsScript = readFileSync(WINDOWS_SCRIPT, "utf8"); + expect(windowsScript).toContain('hermes\\plugins'); + expect(windowsScript).toContain('Linked durable provider'); + expect(windowsScript).toContain('Linked legacy source-tree provider'); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 440b23976..4a6b6f89c 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -55,6 +55,21 @@ function traceKind(trace: TraceDTO): string { : "assistant"); } +async function waitFor( + probe: () => T | null | undefined, + timeoutMs = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = probe(); + if (value != null) return value; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const value = probe(); + if (value != null) return value; + throw new Error("condition was not met before timeout"); +} + beforeEach(() => { db = makeTmpDb(); }); @@ -1082,11 +1097,26 @@ describe("bootstrapMemoryCore", () => { }); await core.init(); - const readDb = new Sqlite(home.home.dbFile, { readonly: true }); - const episode = readDb + const initialReadDb = new Sqlite(home.home.dbFile, { readonly: true }); + const initialEpisode = initialReadDb .prepare("SELECT r_task, meta_json FROM episodes WHERE id = ?") .get("ep_dirty") as { r_task: number | null; meta_json: string } | undefined; - readDb.close(); + initialReadDb.close(); + expect(initialEpisode?.r_task).toBe(0.7); + + const episode = await waitFor(() => { + const readDb = new Sqlite(home.home.dbFile, { readonly: true }); + try { + const row = readDb + .prepare("SELECT r_task, meta_json FROM episodes WHERE id = ?") + .get("ep_dirty") as { r_task: number | null; meta_json: string } | undefined; + if (!row || row.r_task !== 0) return null; + const meta = JSON.parse(row.meta_json) as { recoveryReason?: string }; + return meta.recoveryReason === "dirty_reward_rescore" ? row : null; + } finally { + readDb.close(); + } + }); expect(episode).toBeDefined(); expect(episode!.r_task).toBe(0); @@ -1173,11 +1203,19 @@ describe("bootstrapMemoryCore", () => { }); await core.init(); - const readDb = new Sqlite(home.home.dbFile, { readonly: true }); - const episode = readDb - .prepare("SELECT r_task, meta_json FROM episodes WHERE id = ?") - .get("ep_missing_reward") as { r_task: number | null; meta_json: string } | undefined; - readDb.close(); + const episode = await waitFor(() => { + const readDb = new Sqlite(home.home.dbFile, { readonly: true }); + try { + const row = readDb + .prepare("SELECT r_task, meta_json FROM episodes WHERE id = ?") + .get("ep_missing_reward") as { r_task: number | null; meta_json: string } | undefined; + if (!row || row.r_task !== 0) return null; + const meta = JSON.parse(row.meta_json) as { recoveryReason?: string }; + return meta.recoveryReason === "dirty_reward_rescore" ? row : null; + } finally { + readDb.close(); + } + }); expect(episode).toBeDefined(); expect(episode!.r_task).toBe(0);