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
75 changes: 57 additions & 18 deletions apps/memos-local-plugin/bridge.cts
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,71 @@ async function main(): Promise<void> {
pathToEsmUrl(path.resolve(__dirname, "server/http.ts"))
)) as typeof import("./server/http.js");

const pkgVersion = "2.0.0-alpha.1";
const pkgVersion = require("./package.json").version;
const { core, config, home } = await bootstrapMemoryCoreFull({
agent: args.agent,
pkgVersion,
});
await core.init();

// Default transport: stdio. Daemon + TCP support arrives in V1.1.
const stdio = startStdioServer({ core });

// Per-agent fixed viewer port. We deliberately ignore
// `config.viewer.port` so old config.yaml files (which baked in
// the legacy single-port :18799) don't collide between agents.
// Users who really want a different port should `lsof`/`nc` the
// collision themselves rather than edit a YAML field.
// Per-agent fixed viewer port.
const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const;
const viewerPort = AGENT_DEFAULT_PORTS[args.agent];

// ─── Daemon mode ──────────────────────────────────────────────
// When started with `--daemon`, skip stdio and run as a pure HTTP
// viewer daemon. Used by install.sh (post-install) and admin/restart
// (self-restart) to keep the Memory Viewer always available.
if (args.daemon) {
let viewer: import("./server/types.js").ServerHandle | null = null;
try {
viewer = await startHttpServer(
{
core,
home,
logTail: () => memoryBuffer().tail({ limit: 200 }),
},
{
port: viewerPort,
host: config.viewer.bindHost,
staticRoot: path.resolve(__dirname, "web/dist"),
agent: args.agent,
},
);
process.stderr.write(
`bridge: daemon viewer live at ${viewer.url} (agent=${args.agent})\n`,
);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e?.code === "EADDRINUSE") {
process.stderr.write(
`bridge: daemon port :${viewerPort} already in use — exiting.\n`,
);
await core.shutdown();
process.exit(1);
}
process.stderr.write(
`bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`,
);
await core.shutdown();
process.exit(1);
}

const shutdownDaemon = async (sig: string) => {
process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`);
try { await viewer!.close(); } catch { /* best-effort */ }
await core.shutdown();
process.exit(0);
};
process.on("SIGINT", () => void shutdownDaemon("SIGINT"));
process.on("SIGTERM", () => void shutdownDaemon("SIGTERM"));
// Process stays alive via the HTTP server's ref'd socket.
return;
}

// ─── Normal (stdio) mode ──────────────────────────────────────
const stdio = startStdioServer({ core });

// Try to bind the viewer port. EADDRINUSE → stay headless.
let viewer: import("./server/types.js").ServerHandle | null = null;
try {
Expand Down Expand Up @@ -135,27 +182,19 @@ async function main(): Promise<void> {

// If a viewer is running, keep the process alive as a daemon so the
// memory panel stays accessible between `hermes chat` sessions.
// The next `hermes chat` will spawn a new headless bridge (EADDRINUSE
// on the viewer port); this daemon stays for the viewer only.
if (viewer && !viewer.closed) {
process.stderr.write(
`bridge: stdin closed but viewer is still serving at ${viewer.url} — ` +
`staying alive as daemon. Send SIGTERM to stop.\n`,
);
// Unref'd interval keeps the event loop alive without preventing
// graceful exit on SIGTERM/SIGINT (handled above).
const keepalive = setInterval(() => {
if (viewer!.closed) {
clearInterval(keepalive);
void core.shutdown().then(() => process.exit(0));
}
}, 5_000);
// Don't let the keepalive timer keep the process alive if
// everything else (viewer, core) has been torn down.
(keepalive as unknown as { unref?: () => void }).unref?.();
// ...but DO ref the viewer's server socket so the process stays
// alive for HTTP requests. The server is already ref'd by default.
return; // don't fall through to shutdown + exit
return;
}

// No viewer (headless bridge) — clean exit.
Expand Down
123 changes: 102 additions & 21 deletions apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,12 @@ export function createMemoryCore(
role: inferTurnRole(tc),
action: phase === "lite" ? ("stored" as const) : ("reflected" as const),
summary: tc.reflection?.text ?? null,
content: (tc.userText || tc.agentText || "").slice(0, 400),
content: (
tc.userText ||
tc.agentText ||
summarizeToolCalls(tc.toolCalls) ||
""
).slice(0, 400),
traceId: tc.traceId,
}));
handle.repos.apiLogs.insert({
Expand Down Expand Up @@ -555,6 +560,40 @@ export function createMemoryCore(
}

async function health(): Promise<CoreHealth> {
// Read the latest on-disk config so that model names reflect what
// the user last saved, even before a restart applies the change.
let diskConfig: Record<string, unknown> | null = null;
try {
const { loadConfig } = await import("../config/index.js");
const { config } = await loadConfig(handle.home);
diskConfig = config as unknown as Record<string, unknown>;
} catch {
/* fall through to in-memory */
}

const llmInfo = llmHealth(handle.llm, latestTraceTs());
const embedderInfo = embedderHealth(handle.embedder, latestTraceTs());
const skillEvolverInfo = resolveSkillEvolver(
diskConfig ?? handle.config,
handle.llm,
latestTraceTs(),
);

// Override model names from disk config if they differ from the
// in-memory client (user saved new settings but hasn't restarted).
if (diskConfig) {
const diskLlm = diskConfig.llm as { model?: string; provider?: string } | undefined;
if (diskLlm?.model && diskLlm.model !== llmInfo.model) {
llmInfo.model = diskLlm.model;
if (diskLlm.provider) llmInfo.provider = diskLlm.provider;
}
const diskEmb = diskConfig.embedding as { model?: string; provider?: string } | undefined;
if (diskEmb?.model && diskEmb.model !== embedderInfo.model) {
embedderInfo.model = diskEmb.model;
if (diskEmb.provider) embedderInfo.provider = diskEmb.provider;
}
}

return {
ok: initialized && !shutDown,
version: pkgVersion,
Expand All @@ -567,17 +606,9 @@ export function createMemoryCore(
skills: home.skillsDir,
logs: home.logsDir,
},
// V7 overview card: fall back to the newest captured trace as
// a proxy for "LLM + embedder were OK recently" when the live
// `stats().lastOkAt` counter hasn't yet been populated in this
// process. Every captured trace is proof that reflection / α
// scoring (LLM) and summary embedding (embedder) both
// succeeded at that moment — so reading the DB max ts gives a
// correct, non-fabricated lower bound that survives plugin
// restarts without misleading the user.
llm: llmHealth(handle.llm, latestTraceTs()),
embedder: embedderHealth(handle.embedder, latestTraceTs()),
skillEvolver: resolveSkillEvolver(handle.config, handle.llm, latestTraceTs()),
llm: llmInfo,
embedder: embedderInfo,
skillEvolver: skillEvolverInfo,
};
}

Expand Down Expand Up @@ -1383,6 +1414,8 @@ export function createMemoryCore(
tags: tagSet.size > 0 ? Array.from(tagSet).sort() : undefined,
skillStatus: derivation.status,
skillReason: derivation.reason,
skillReasonKey: derivation.reasonKey,
skillReasonParams: derivation.reasonParams,
linkedSkillId: derivation.linkedSkillId,
closeReason,
abandonReason,
Expand Down Expand Up @@ -2490,77 +2523,125 @@ export function deriveSkillStatus(
): {
status: EpisodeListItemDTO["skillStatus"];
reason: string | null;
reasonKey: string | null;
reasonParams: Record<string, string> | null;
linkedSkillId: SkillId | null;
} {
if (ep.status === "open") {
return { status: "queued", reason: "任务仍在进行中,技能流水线尚未启动", linkedSkillId: null };
return {
status: "queued",
reason: "任务仍在进行中,技能流水线尚未启动",
reasonKey: "tasks.skillReason.queued.inProgress",
reasonParams: null,
linkedSkillId: null,
};
}
if (ep.rTask == null) {
return {
status: "queued",
reason: "Reward 评分尚未完成,技能流水线将在评分后启动",
reasonKey: "tasks.skillReason.queued.rewardPending",
reasonParams: null,
linkedSkillId: null,
};
}
if (ep.rTask <= R_NEGATIVE_FLOOR) {
return {
status: "skipped",
reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例;不会沉淀出新的 L2 经验或技能,但原始 L1 轨迹会作为反面教材保留,在后续 Decision Repair 中生成 anti-pattern 规避下次同类错误`,
reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例`,
reasonKey: "tasks.skillReason.skipped",
reasonParams: { rTask: ep.rTask.toFixed(2) },
linkedSkillId: null,
};
}
if (ep.rTask < R_BELOW_THRESHOLD) {
return {
status: "not_generated",
reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值 (≥ ${R_BELOW_THRESHOLD.toFixed(2)})——对话本身正常,只是还不够强到能泛化成 L2 经验;多做几个相似任务后会自动积累`,
reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值`,
reasonKey: "tasks.skillReason.not_generated.belowThreshold",
reasonParams: { rTask: ep.rTask.toFixed(2), threshold: R_BELOW_THRESHOLD.toFixed(2) },
linkedSkillId: null,
};
}
if (relatedPolicies.length === 0) {
return {
status: "not_generated",
reason:
"暂未归纳出 L2 经验——单个任务无法跨任务泛化;需要至少 2 个相似任务(minEpisodesForInduction),且 V 值 ≥ 0.1 才能触发 L2 诱导,之后支撑 ≥ 3 个相似任务才会结晶为技能",
reason: "暂未归纳出 L2 经验",
reasonKey: "tasks.skillReason.not_generated.noPolicy",
reasonParams: null,
linkedSkillId: null,
};
}
const best = [...relatedPolicies].sort((a, b) => b.gain - a.gain)[0]!;
const policyBucket = skillsByPolicy.get(best.id) ?? [];
if (policyBucket.length > 0) {
const active = policyBucket.find((s) => s.status !== "archived") ?? policyBucket[0]!;
const isUpgraded = best.updatedAt > active.updatedAt;
return {
status: best.updatedAt > active.updatedAt ? "upgraded" : "generated",
status: isUpgraded ? "upgraded" : "generated",
reason: `技能「${active.name ?? active.id}」已从经验 ${best.id.slice(0, 8)} 结晶`,
reasonKey: isUpgraded ? "tasks.skillReason.upgraded" : "tasks.skillReason.generated",
reasonParams: { skillName: active.name ?? active.id, policyId: best.id.slice(0, 8) },
linkedSkillId: active.id as SkillId,
};
}
if (best.status !== "active") {
return {
status: "queued",
reason: `经验 ${best.id.slice(0, 8)} 状态为 ${best.status}——需要更多支撑任务才能结晶为技能(当前 support=${best.support ?? 0},需 ≥3)`,
reason: `经验 ${best.id.slice(0, 8)} 需要更多支撑任务`,
reasonKey: "tasks.skillReason.queued.policyPending",
reasonParams: { support: String(best.support ?? 0) },
linkedSkillId: null,
};
}
return {
status: "queued",
reason: `经验 ${best.id.slice(0, 8)} 已就绪(gain=${best.gain.toFixed(2)},support=${best.support ?? 0}),技能结晶将在下次 reward 评分后自动触发`,
reason: `经验 ${best.id.slice(0, 8)} 已就绪`,
reasonKey: "tasks.skillReason.queued.ready",
reasonParams: { gain: best.gain.toFixed(2), support: String(best.support ?? 0) },
linkedSkillId: null,
};
}

/**
* Produce a short content string from toolCalls when userText/agentText
* are both empty (sub-steps after the first in a multi-tool turn).
*/
function summarizeToolCalls(
toolCalls?: readonly { name?: string; output?: unknown }[] | null,
): string {
if (!toolCalls || toolCalls.length === 0) return "";
return toolCalls
.map((tc) => {
const name = tc.name ?? "tool";
const out = typeof tc.output === "string"
? tc.output.slice(0, 200)
: tc.output != null
? JSON.stringify(tc.output).slice(0, 200)
: "";
return out ? `[${name}] ${out}` : `[${name}]`;
})
.join("\n");
}

/**
* Heuristic role inference for api_logs "memory_add" rows — mirrors
* the legacy plugin's behaviour where each captured turn showed up
* labelled `user` / `assistant` / `tool` on the Logs page.
*
* Priority: if the step carries userText (the user's query), label it
* "user" even when toolCalls are present — this is the first sub-step
* of a multi-tool turn and semantically represents the user request.
*/
function inferTurnRole(step: {
userText?: string;
agentText?: string;
toolCalls?: readonly unknown[];
}): "user" | "assistant" | "tool" | "other" {
if ((step.toolCalls?.length ?? 0) > 0) return "tool";
const u = (step.userText ?? "").length;
const a = (step.agentText ?? "").length;
if (u > 0 && (step.toolCalls?.length ?? 0) > 0) return "user";
if ((step.toolCalls?.length ?? 0) > 0) return "tool";
if (u >= a && u > 0) return "user";
if (a > 0) return "assistant";
return "other";
Expand Down
30 changes: 9 additions & 21 deletions apps/memos-local-plugin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -687,38 +687,26 @@ CFGEOF
if command -v lsof >/dev/null 2>&1 && lsof -i ":${HERMES_PORT}" -t >/dev/null 2>&1; then
warn "Port :${HERMES_PORT} already in use — skipping smoke test."
else
step "Running bridge smoke test"
step "Starting Memory Viewer daemon"
local tsx_bin="${prefix}/node_modules/.bin/tsx"
local bridge_cts="${prefix}/bridge.cts"
if [[ -x "${tsx_bin}" && -f "${bridge_cts}" ]]; then
local smoke_log smoke_fifo smoke_pid sleeper_pid
smoke_log="$(mktemp)"
smoke_fifo="$(mktemp -u)"
mkfifo "${smoke_fifo}"
# Keep stdin open via a FIFO so the bridge doesn't exit on EOF.
sleep 60 > "${smoke_fifo}" &
sleeper_pid=$!
disown "${sleeper_pid}" 2>/dev/null || true
( cd "${prefix}" && "${tsx_bin}" "${bridge_cts}" --agent=hermes <"${smoke_fifo}" >"${smoke_log}" 2>&1 ) &
smoke_pid=$!
disown "${smoke_pid}" 2>/dev/null || true
local daemon_log="${prefix}/logs/daemon-start.log"
mkdir -p "${prefix}/logs"
# Launch bridge in --daemon mode (pure HTTP, no stdio).
# The process stays alive to serve the Memory Viewer.
( cd "${prefix}" && nohup "${tsx_bin}" "${bridge_cts}" --agent=hermes --daemon >"${daemon_log}" 2>&1 ) &
disown $! 2>/dev/null || true

if wait_for_viewer "${HERMES_PORT}"; then
success "Bridge smoke test passed"
success "Memory Viewer daemon running"
else
error "Memory Viewer did not respond within 30s."
warn "Re-install dependencies and re-run: cd ${prefix} && npm install"
kill "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true
kill -9 "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true
rm -f "${smoke_log}" "${smoke_fifo}"
return 1
fi

kill "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true
kill -9 "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true
rm -f "${smoke_log}" "${smoke_fifo}"
else
warn "tsx not found — skipping smoke test."
warn "tsx not found — skipping daemon start."
fi
fi

Expand Down
Loading
Loading