Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/adapters/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 15 additions & 11 deletions apps/memos-local-plugin/adapters/hermes/install.hermes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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\" <hermes-plugins>/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."
Expand Down
19 changes: 18 additions & 1 deletion apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ export function createMemoryCore(
);
let lastStaleScan = 0;
let lastDirtyClosedScan = 0;
let startupDirtyRecoveryScheduled = false;
async function autoFinalizeStaleTasks(): Promise<void> {
const nowMs = Date.now();
if (nowMs - lastStaleScan < 30_000) return;
Expand Down Expand Up @@ -584,6 +585,22 @@ export function createMemoryCore(
}
}

function scheduleDirtyClosedRecovery(
episodes: Array<EpisodeRow & { meta?: Record<string, unknown> }>,
): 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<void> {
if (shutDown) {
Expand Down Expand Up @@ -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", {
Expand Down
25 changes: 17 additions & 8 deletions apps/memos-local-plugin/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @"
Expand Down
29 changes: 21 additions & 8 deletions apps/memos-local-plugin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apps/memos-local-plugin/tests/unit/install/install-sh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}) {
Expand Down Expand Up @@ -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');
});
});
54 changes: 46 additions & 8 deletions apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ function traceKind(trace: TraceDTO): string {
: "assistant");
}

async function waitFor<T>(
probe: () => T | null | undefined,
timeoutMs = 1_000,
): Promise<T> {
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();
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down