feat(dsh): add Environment tab with local Python detection

Adds a read-only Environment tab to the ReMe Memory settings card so
users can see why the ReMe service is unreachable before touching a
terminal:

- discover local Python interpreters via the PEP 514 registry, py
  launcher, PATH and conda (base + named envs), then verify every
  candidate by actually running it
- classify origins (conda base/env, python.org, Microsoft Store, venv,
  system PATH), drop Store alias stubs, and suggest a preferred
  interpreter (Python >= 3.11, named conda envs first)
- expose detection to the browser through a new remeEnv host gateway
  (TypertRemoteService) and render it with a connection health check
- config gains optional authToken and pythonPath fields with unchanged
  defaults; existing memory settings keep their behavior

Parsing is fixture-tested against real tool output on Windows (GBK
codepages, localized reg labels, missing py launcher) and Linux.
This commit is contained in:
LiangshouX 2026-10-27 16:57:36 +08:00
parent c23cc64715
commit 6f1ecf06aa
14 changed files with 1820 additions and 73 deletions

View file

@ -0,0 +1,405 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import { safeError } from "../endpoint.js";
import type {
PythonDetection,
PythonEnv,
PythonOrigin,
} from "../pyenv/types.js";
import { ReMeClient } from "../reme/client.js";
import type { ReMeSettings } from "../types.js";
import type { StatusRpc } from "./status-page.js";
const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
export const envEn = {
connectionTitle: "Service connection",
endpointLabel: "Service URL",
checking: "Checking…",
connected: "Connected",
disconnected: "Not connected",
retest: "Test connection",
remoteNotice:
"This service URL is not local, so local environment management is disabled.",
pythonTitle: "Local Python",
pythonHint:
"ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
detecting: "Detecting Python environments…",
detectFailed: "Detection failed",
refresh: "Refresh",
emptyTitle: "No usable Python found",
emptyHint:
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
miniconda: "Miniconda download",
recommended: "recommended",
okBadge: "OK",
tooOld: "Needs 3.11+",
originConda: "Conda ({name})",
originPythonOrg: "python.org installer",
originStore: "Microsoft Store",
originVenv: "venv environment",
originSystem: "System PATH",
originUnknown: "Unknown origin",
errorsTitle: "Probe details",
dependencyTitle: "ReMe dependency",
configTitle: "ReMe parameters",
controlTitle: "Service control",
upcoming:
"This section arrives with the next milestone of the environment feature.",
};
export const envZh: typeof envEn = {
connectionTitle: "服务连接",
endpointLabel: "服务地址",
checking: "正在检查…",
connected: "已连接",
disconnected: "未连接",
retest: "测试连接",
remoteNotice: "当前服务地址不是本机地址,本地环境管理已停用。",
pythonTitle: "本地 Python 环境",
pythonHint:
"ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
detecting: "正在探测本地 Python 环境…",
detectFailed: "探测失败",
refresh: "重新探测",
emptyTitle: "未检测到可用的 Python 环境",
emptyHint:
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
miniconda: "下载 Miniconda",
recommended: "推荐",
okBadge: "可用",
tooOld: "需要 3.11+",
originConda: "Conda({name})",
originPythonOrg: "python.org 安装器",
originStore: "Microsoft Store",
originVenv: "venv 虚拟环境",
originSystem: "系统 PATH",
originUnknown: "来源未知",
errorsTitle: "探测细节",
dependencyTitle: "ReMe 依赖",
configTitle: "ReMe 参数",
controlTitle: "服务控制",
upcoming: "该分区将随环境管理特性的后续里程碑提供。",
};
export type EnvTranslator = (key: keyof typeof envEn) => string;
export interface ReMeEnvironmentTabProps {
scope: ConfigForm<ReMeSettings>;
rpc: StatusRpc;
t: EnvTranslator;
}
interface DetectState {
phase: "loading" | "ready" | "error";
data?: PythonDetection;
error?: string;
}
interface HealthState {
phase: "loading" | "ok" | "fail";
/** The endpoint this result was measured against. */
endpoint: string;
version?: string;
error?: string;
}
function isLocalEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname);
} catch {
return false;
}
}
function parseDetection(value: unknown): PythonDetection {
if (
typeof value !== "object" ||
value === null ||
!Array.isArray((value as { envs?: unknown }).envs)
)
throw new Error("Invalid environment detection response");
return value as unknown as PythonDetection;
}
function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
switch (origin.kind) {
case "conda":
return t("originConda").replace("{name}", origin.envName);
case "python.org":
return t("originPythonOrg");
case "windows-store":
return t("originStore");
case "venv":
return t("originVenv");
case "system":
return t("originSystem");
default:
return t("originUnknown");
}
}
/** Environment management panel: local service connection, Python, service. */
export function ReMeEnvironmentTab({
scope,
rpc,
t,
}: ReMeEnvironmentTabProps): JSX.Element | null {
const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
);
const value = snapshot.value;
const endpoint = value?.endpoint ?? "";
const [detection, setDetection] = useState<DetectState>({
phase: "loading",
});
const [health, setHealth] = useState<HealthState>({
phase: "loading",
endpoint: "",
});
const [detectTick, setDetectTick] = useState(0);
const [healthTick, setHealthTick] = useState(0);
const [savingExe, setSavingExe] = useState("");
const loadDetection = useCallback(
async (signal: AbortSignal, refresh: boolean) => {
try {
const result = await rpc.call(
"/api",
"remeEnv/python",
{ args: { refresh } },
signal,
);
if (!result.ok)
throw new Error(result.error.message || result.error.code);
setDetection((current) => ({
...current,
phase: "ready",
data: parseDetection(result.value),
}));
} catch (error) {
if (!signal.aborted)
setDetection((current) => ({
...current,
phase: "error",
error: safeError(error),
}));
}
},
[rpc],
);
useEffect(() => {
const controller = new AbortController();
void loadDetection(controller.signal, detectTick > 0);
return () => controller.abort();
}, [loadDetection, detectTick]);
useEffect(() => {
if (!endpoint) return;
const controller = new AbortController();
const client = new ReMeClient({
endpoint,
requestTimeoutMs: value?.requestTimeoutMs ?? 10000,
backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000,
});
void client
.healthCheck({ signal: controller.signal })
.then((result) => {
if (controller.signal.aborted) return;
if (result.ok)
setHealth({
phase: "ok",
endpoint,
version: result.health?.version ?? "",
});
else setHealth({ phase: "fail", endpoint, error: result.error });
})
.catch((error: unknown) => {
if (!controller.signal.aborted)
setHealth({ phase: "fail", endpoint, error: safeError(error) });
});
return () => controller.abort();
// Reload when connection settings or the manual retest tick change.
}, [
endpoint,
value?.requestTimeoutMs,
value?.backgroundTimeoutMs,
healthTick,
]);
if (snapshot.status === "unavailable") return null;
const detected = detection.phase === "ready" ? detection.data : undefined;
const select = (env: PythonEnv): void => {
if (!value || !snapshot.writable || savingExe) return;
setSavingExe(env.exe);
void scope
.mutate(
[{ op: "set", path: ["pythonPath"], value: env.exe }],
snapshot.revision,
)
.catch(() => undefined)
.finally(() => setSavingExe(""));
};
const local = isLocalEndpoint(endpoint);
const selectedExe = value?.pythonPath ?? "";
const healthCurrent =
health.phase !== "loading" && health.endpoint === endpoint;
return (
<div className="reme-env">
<section className="reme-settings-section">
<h4>{t("connectionTitle")}</h4>
<div className="reme-env-row">
<span className="reme-env-label">{t("endpointLabel")}</span>
<code className="reme-env-endpoint">{endpoint}</code>
{!healthCurrent ? (
<span className="reme-status-badge">
<i />
{t("checking")}
</span>
) : health.phase === "ok" ? (
<span className="reme-status-badge ok">
<i />
{health.version ? `ReMe ${health.version}` : t("connected")}
</span>
) : (
<span className="reme-status-badge bad">
<i />
{t("disconnected")}
</span>
)}
<button
type="button"
className="reme-settings-button"
onClick={() => {
setHealth({ phase: "loading", endpoint });
setHealthTick((tick) => tick + 1);
}}
>
{t("retest")}
</button>
</div>
{healthCurrent && health.phase === "fail" && health.error ? (
<p className="reme-settings-error">{health.error}</p>
) : null}
</section>
{!local ? (
<p className="reme-settings-muted reme-env-notice">
{t("remoteNotice")}
</p>
) : (
<>
<section className="reme-settings-section">
<h4>{t("pythonTitle")}</h4>
<p className="reme-settings-muted">{t("pythonHint")}</p>
<div className="reme-env-row">
{detection.phase === "loading" ? (
<span className="reme-settings-muted">{t("detecting")}</span>
) : null}
<button
type="button"
className="reme-settings-button"
onClick={() => {
setDetection({ phase: "loading" });
setDetectTick((tick) => tick + 1);
}}
>
{t("refresh")}
</button>
</div>
{detection.phase === "error" ? (
<p className="reme-settings-error">
{t("detectFailed")}
{detection.error ? `: ${detection.error}` : ""}
</p>
) : null}
{detected && detected.envs.length === 0 ? (
<div className="reme-env-hint">
<strong>{t("emptyTitle")}</strong>
<span>{t("emptyHint")}</span>
<a
className="reme-settings-button"
href={MINICONDA_URL}
target="_blank"
rel="noreferrer"
>
{t("miniconda")}
</a>
</div>
) : null}
{detected && detected.envs.length > 0 ? (
<div className="reme-env-list">
{detected.envs.map((env) => (
<label
key={env.exe}
className={`reme-env-python${
selectedExe === env.exe ? " active" : ""
}`}
>
<input
type="radio"
name="reme-python-interpreter"
checked={selectedExe === env.exe}
disabled={
!env.ok || savingExe !== "" || !snapshot.writable
}
onChange={() => select(env)}
/>
<span className="reme-env-python-main">
<strong>
{`Python ${env.version || "?"}`}
{selectedExe !== env.exe &&
env.exe === detected.suggested
? ` · ${t("recommended")}`
: ""}
</strong>
<small>{`${originLabel(t, env.origin)} · ${
env.exe
}`}</small>
</span>
<span
className={`reme-status-badge ${env.ok ? "ok" : "bad"}`}
>
<i />
{env.ok ? t("okBadge") : t("tooOld")}
</span>
</label>
))}
</div>
) : null}
{detected && detected.errors.length > 0 ? (
<details className="reme-settings-details">
<summary>{t("errorsTitle")}</summary>
<pre>{detected.errors.join("\n")}</pre>
</details>
) : null}
</section>
<section className="reme-settings-section">
<h4>{t("dependencyTitle")}</h4>
<p className="reme-settings-muted reme-env-upcoming">
{t("upcoming")}
</p>
</section>
<section className="reme-settings-section">
<h4>{t("configTitle")}</h4>
<p className="reme-settings-muted reme-env-upcoming">
{t("upcoming")}
</p>
</section>
<section className="reme-settings-section">
<h4>{t("controlTitle")}</h4>
<p className="reme-settings-muted reme-env-upcoming">
{t("upcoming")}
</p>
</section>
</>
)}
</div>
);
}

View file

@ -3,6 +3,12 @@ import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { ReMeSettings } from "../types.js";
import { validEndpoint } from "../endpoint.js";
import {
envEn,
envZh,
ReMeEnvironmentTab,
type EnvTranslator,
} from "./env-tab.js";
import {
ReMeStatusPage,
statusEn,
@ -14,6 +20,7 @@ import { styles } from "./styles.js";
const NS = "reme.settings";
const STATUS_NS = "reme.status";
const ENV_NS = "reme.environment";
const SETTINGS_NS = "reme-memory";
const FIELDS = [
"endpoint",
@ -42,6 +49,7 @@ interface ClientContext {
locale: {
bind(namespace: typeof NS): Translator;
bind(namespace: typeof STATUS_NS): StatusTranslator;
bind(namespace: typeof ENV_NS): EnvTranslator;
register(
namespace: string,
dictionaries: {
@ -84,6 +92,8 @@ interface Draft {
interface ReMeCardProps {
scope: ConfigForm<ReMeSettings>;
t: Translator;
rpc?: StatusRpc;
envT?: EnvTranslator;
view?: "summary" | "page";
}
@ -121,6 +131,8 @@ const en = {
retryDelay: "Retry delay (ms)",
retryMaxDelay: "Maximum retry delay (ms)",
diagnostics: "Service diagnostics",
tabMemory: "Memory settings",
tabEnvironment: "Environment",
processMemory: "Process memory",
componentMemory: "Component memory",
components: "Health components",
@ -196,6 +208,8 @@ const zh: typeof en = {
retryDelay: "失败重试间隔(毫秒)",
retryMaxDelay: "最大重试间隔(毫秒)",
diagnostics: "服务诊断",
tabMemory: "记忆设置",
tabEnvironment: "环境管理",
processMemory: "进程内存",
componentMemory: "组件内存",
components: "健康组件",
@ -250,7 +264,12 @@ export function apply(ctx: ClientContext): void {
() => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }),
"remeMemory.statusLocale()",
);
ctx.effect(
() => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }),
"remeMemory.envLocale()",
);
ctx.effect(() => installStyles(), "remeMemory.settingsStyles()");
const envT = ctx.locale.bind(ENV_NS);
const scope = ctx.configForms.get<ReMeSettings>(SETTINGS_NS);
ctx.effect(
() =>
@ -262,7 +281,12 @@ export function apply(ctx: ClientContext): void {
key: "@agentscope-ai/reme-dsh-plugin#reme-memory",
label: () => t("title"),
locale: NS,
inject: () => ({ scope, t }),
inject: () => ({
scope,
t,
rpc: ctx.get("connection").rpc,
envT,
}),
},
ReMeSettingsCard,
),
@ -291,6 +315,8 @@ export function apply(ctx: ClientContext): void {
function ReMeSettingsCard({
scope,
t,
rpc,
envT,
view,
}: ReMeCardProps): JSX.Element | null {
const snapshot = useSyncExternalStore(
@ -304,6 +330,7 @@ function ReMeSettingsCard({
const [resetAll, setResetAll] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
const [tab, setTab] = useState<"memory" | "environment">("memory");
const value = snapshot.value;
const draft =
draftOverride?.value ??
@ -316,7 +343,20 @@ function ReMeSettingsCard({
(value !== undefined &&
draft !== undefined &&
!sameDraft(draft, draftFrom(value)));
const validation = draft === undefined ? undefined : parseDraft(draft);
const validation =
draft === undefined
? undefined
: parseDraft(
draft,
value && isRecord(value)
? {
authToken:
typeof value.authToken === "string" ? value.authToken : "",
pythonPath:
typeof value.pythonPath === "string" ? value.pythonPath : "",
}
: undefined,
);
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
if (value === undefined) return;
@ -373,79 +413,115 @@ function ReMeSettingsCard({
return (
<div className="reme-settings-card">
<div className="reme-settings-body">
{dirty ? <p className="reme-settings-pending">{t("unsaved")}</p> : null}
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
<div className="reme-settings-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === "memory"}
className={tab === "memory" ? "active" : ""}
onClick={() => setTab("memory")}
>
{t("tabMemory")}
</button>
<button
type="button"
role="tab"
aria-selected={tab === "environment"}
className={tab === "environment" ? "active" : ""}
onClick={() => setTab("environment")}
>
{t("tabEnvironment")}
</button>
</div>
{tab === "environment" ? (
rpc && envT ? (
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
) : (
<p className="reme-settings-muted">{t("loading")}</p>
)
) : (
<>
<fieldset
className="reme-settings-fields"
disabled={saving || !snapshot.writable}
>
<SettingsForm draft={draft} update={update} t={t} />
</fieldset>
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
{dirty ? (
<p className="reme-settings-pending">{t("unsaved")}</p>
) : null}
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft({
value: draftFrom(baseSettings(snapshot.base, value)),
revision: snapshot.revision,
});
setResetAll(true);
setSaveState("");
}}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">{t("saveFailed")}</span>
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
<fieldset
className="reme-settings-fields"
disabled={saving || !snapshot.writable}
>
<SettingsForm draft={draft} update={update} t={t} />
</fieldset>
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">{t("saved")}</span>
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(undefined);
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft({
value: draftFrom(baseSettings(snapshot.base, value)),
revision: snapshot.revision,
});
setResetAll(true);
setSaveState("");
}}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">
{t("saveFailed")}
</span>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">
{t("saved")}
</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(undefined);
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
)}
</>
)}
</div>
@ -677,7 +753,10 @@ function draftFrom(value: ReMeSettings): Draft {
};
}
function parseDraft(draft: Draft): ReMeSettings | undefined {
function parseDraft(
draft: Draft,
passthrough?: Pick<ReMeSettings, "authToken" | "pythonPath">,
): ReMeSettings | undefined {
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
const backgroundTimeoutMs = boundedInteger(
draft.backgroundTimeoutMs,
@ -718,6 +797,8 @@ function parseDraft(draft: Draft): ReMeSettings | undefined {
const [hour, minute] = draft.dreamTime.split(":");
return {
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
authToken: passthrough?.authToken ?? "",
pythonPath: passthrough?.pythonPath ?? "",
requestTimeoutMs: requestTimeoutMs!,
backgroundTimeoutMs: backgroundTimeoutMs!,
shutdownTimeoutMs: shutdownTimeoutMs!,

View file

@ -31,5 +31,27 @@ export const styles = `
.reme-components-hero{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:linear-gradient(135deg,color-mix(in srgb,#21a366 6%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:18px 20px}.reme-components-hero h3{margin:3px 0 0;font-size:20px}.reme-components-hero p{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px}.reme-components-score{display:grid;grid-template-columns:auto auto;align-items:baseline}.reme-components-score strong{font-size:25px}.reme-components-score>span{color:var(--dsw-alias-label-tertiary);font-size:13px}.reme-components-score small{grid-column:1 / -1;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-status-components .reme-status-component-grid{margin-top:12px}.reme-status-component{position:relative;overflow:hidden;padding:15px}.reme-status-component:before{position:absolute;top:0;right:0;left:0;height:2px;background:var(--dsw-alias-label-dimmed);content:""}.reme-status-component.healthy:before{background:#21a366}.reme-component-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px!important}.reme-component-identity>span{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:10px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:700}.reme-component-identity>div{display:flex;min-width:0;flex-direction:column;gap:2px}
.reme-library-hero{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:13px;border:1px solid var(--dsw-alias-border-l2);border-bottom:0;border-radius:14px 14px 0 0;background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:15px 17px}.reme-library-hero.knowledge{background:linear-gradient(135deg,color-mix(in srgb,#7656c9 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3))}.reme-library-mark{display:grid;width:38px;height:38px;place-items:center;border-radius:11px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:700}.reme-library-hero h3{margin:0;font-size:15px}.reme-library-hero p{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px}.reme-library-count{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:0 4px;text-align:right}.reme-library-count strong{font-size:18px}.reme-library-count span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-library-count code{grid-column:1 / -1;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-file-search{position:sticky;z-index:1;top:-8px;display:flex;align-items:center;gap:6px;background:inherit;padding:0 0 8px}.reme-file-search input{width:100%;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:7px 9px;color:var(--dsw-alias-label-primary);font:inherit;font-size:10px}.reme-file-search input:focus{border-color:var(--dsw-alias-brand-primary);outline:0}.reme-file-search>span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header{position:sticky;z-index:1;top:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 92%,transparent);padding:14px 18px;backdrop-filter:blur(10px)}.reme-document-preview>header span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header h3{margin:2px 0;font-size:15px}.reme-document-preview>header code{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-frontmatter,.reme-document-content{padding:16px 18px}.reme-frontmatter{border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,transparent)}.reme-frontmatter h4,.reme-document-content>h4{margin:0 0 11px;color:var(--dsw-alias-label-tertiary);font-size:9px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.reme-frontmatter dl{display:grid;gap:8px;margin:0}.reme-frontmatter dl>div{display:grid;grid-template-columns:minmax(76px,max-content) minmax(0,1fr);gap:14px}.reme-frontmatter dt{color:var(--dsw-alias-label-tertiary);font:600 10px ui-monospace,SFMono-Regular,monospace}.reme-frontmatter dd{margin:0;overflow-wrap:anywhere;font-size:11px;line-height:1.5;white-space:pre-wrap}.reme-document-content{padding-bottom:38px}
@media(max-width:900px){.reme-status-metric-grid,.reme-status-dream-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.reme-settings-tabs{display:flex;gap:2px;margin-top:10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
.reme-settings-tabs button{position:relative;appearance:none;border:0;background:none;padding:9px 12px;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;cursor:pointer}
.reme-settings-tabs button:hover{color:var(--dsw-alias-label-primary)}
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.reme-env{display:flex;flex-direction:column}
.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0}
.reme-env-row+.reme-settings-error{margin:0}
.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}
.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
.reme-env-notice{margin:12px 0 0}
.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)}
.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer}
.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)}
.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5}
.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere}
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
.reme-env-hint strong{color:var(--dsw-alias-label-primary)}
.reme-env-upcoming{margin:0;padding:4px 0 12px}
.reme-env .reme-settings-details{margin-top:8px}
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
`;

View file

@ -19,6 +19,18 @@ export const Config = z.object({
"ReMe HTTP service URL",
assertEndpoint,
),
authToken: z
.string()
.default("")
.description(
"Optional bearer token for a self-hosted gateway in front of ReMe",
)
.volatile(),
pythonPath: z
.string()
.default("")
.description("Local Python interpreter selected in environment management")
.volatile(),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
backgroundTimeoutMs: z
.natural()
@ -86,6 +98,8 @@ function checkedString(
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
endpoint: "http://127.0.0.1:2333",
authToken: "",
pythonPath: "",
requestTimeoutMs: 10000,
backgroundTimeoutMs: 3600000,
shutdownTimeoutMs: 5000,
@ -138,6 +152,14 @@ export function resolveConfig(
config.endpoint = stripTrailingSlashes(String(config.endpoint));
assertEndpoint(config.endpoint);
config.authToken =
typeof config.authToken === "string" ? config.authToken.trim() : "";
if (config.authToken && !/^[\x21-\x7e]{1,512}$/.test(config.authToken))
throw new TypeError(
"ReMe auth token must be 1-512 visible ASCII characters without newlines",
);
config.pythonPath =
typeof config.pythonPath === "string" ? config.pythonPath.trim() : "";
const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`;
const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`;
config.autoMemoryQueueDir = resolve(

View file

@ -0,0 +1,29 @@
import type { Context } from "@deepseek-ai/cordis";
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
import type { PythonDetection } from "./pyenv/types.js";
import type { ReMeEnvManager } from "./env-manager.js";
declare module "@deepseek-ai/cordis" {
interface Context {
/** Local environment manager mounted by the DSH adapter. */
remeEnv: ReMeEnvManager;
}
}
/** Read-only Host projection consumed by the environment management tab. */
export class ReMeEnvGateway extends TypertRemoteService {
static inject = ["remeEnv"];
constructor(ctx: Context) {
super(ctx, "remeEnv");
}
/** Detect local Python interpreters; cached briefly unless refreshed. */
@Remote("python")
async python(input: { refresh?: boolean } = {}): Promise<PythonDetection> {
return this.ctx.remeEnv.detectPython(input);
}
}
export default ReMeEnvGateway;

View file

@ -0,0 +1,33 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { detectPythonEnvs } from "./pyenv/detect.js";
import { createNodeRunner } from "./pyenv/runner.js";
import type { PythonDetection } from "./pyenv/types.js";
const CACHE_MS = 60_000;
/**
* Host-side local environment manager. Read-only in this milestone: it
* discovers Python interpreters and caches the answer briefly so opening
* the environment tab does not re-run probes on every render.
*/
export class ReMeEnvManager {
private cache?: { at: number; detection: PythonDetection };
async detectPython(
options: { refresh?: boolean } = {},
): Promise<PythonDetection> {
if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS)
return this.cache.detection;
const detection = await detectPythonEnvs({
run: createNodeRunner(process.env),
platform: process.platform,
env: process.env,
homedir: homedir(),
exists: existsSync,
});
this.cache = { at: Date.now(), detection };
return detection;
}
}

View file

@ -6,6 +6,8 @@ import type {} from "@deepseek-ai/dsh-session";
import { ReMeClient } from "./reme/client.js";
import { resolveConfig } from "./config.js";
import { ReMeEnvGateway } from "./env-gateway.js";
import { ReMeEnvManager } from "./env-manager.js";
import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js";
import { ReMeRuntime } from "./runtime.js";
import { ReMeStatusGateway } from "./status-gateway.js";
@ -25,7 +27,9 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
return () => runtime.disposeAll();
}, "remeMemory.lifecycle()");
ctx.provide("remeMemory", runtime);
ctx.provide("remeEnv", new ReMeEnvManager());
void ctx.plugin(ReMeStatusGateway);
void ctx.plugin(ReMeEnvGateway);
ctx.effect(
() => registerReMeTools(ctx, client, current),
"remeMemory.tools()",

View file

@ -0,0 +1,524 @@
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { join as posixJoin } from "node:path/posix";
import { safeError } from "../endpoint.js";
import type { RunCommand, RunResult } from "./runner.js";
import type { PythonDetection, PythonEnv, PythonOrigin } from "./types.js";
/** ReMe requires Python 3.11 or newer. */
export const MINIMUM_PYTHON = "3.11";
/**
* Ask one interpreter for its real executable, version and base prefix.
* Kept as a single self-contained -c snippet so no helper files are needed.
*/
const VERIFY_SNIPPET =
'import sys,json;print(json.dumps({"exe":sys.executable,"ver":"%d.%d.%d"%sys.version_info[:3],"base":sys.base_prefix}))';
export interface DetectIO {
run: RunCommand;
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
homedir: string;
exists(path: string): boolean;
}
export interface VerifyInfo {
exe: string;
version: string;
basePrefix: string;
}
/* ------------------------------------------------------------------ */
/* Pure parsers (unit-tested against real tool output fixtures). */
/* ------------------------------------------------------------------ */
/** Compare "major.minor[.patch]" strings numerically. */
export function versionAtLeast(
version: string,
minimum: string = MINIMUM_PYTHON,
): boolean {
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
if (actual.length < 2 || actual.some((part) => !Number.isFinite(part)))
return false;
const wanted = minimum.split(".").map((part) => Number.parseInt(part, 10));
for (let index = 0; index < wanted.length; index += 1) {
const left = actual[index] ?? 0;
const right = wanted[index] ?? 0;
if (left !== right) return left > right;
}
return true;
}
/** Parse the VERIFY_SNIPPET line out of mixed stdout (banners, warnings). */
export function parseVerifyOutput(text: string): VerifyInfo | undefined {
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const value = JSON.parse(trimmed) as Record<string, unknown>;
const exe = typeof value.exe === "string" ? value.exe : "";
const version = typeof value.ver === "string" ? value.ver : "";
const base = typeof value.base === "string" ? value.base : "";
if (exe && version) return { exe, version, basePrefix: base };
} catch {
// Not JSON; try the next line.
}
}
return undefined;
}
export interface RegPython {
exe: string;
source: string;
}
/**
* Parse `reg query <hive> /s /ve` output for PEP 514 InstallPath defaults.
* The default-value label is localized and arrives mojibaked when reg.exe
* writes GBK into a UTF-8 pipe, so match the ASCII "REG_SZ" marker instead.
*/
export function parseRegQuery(text: string): RegPython[] {
const found: RegPython[] = [];
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const key = (lines[index] ?? "").trim();
if (!key.startsWith("HKEY_") || !key.endsWith("\\InstallPath")) continue;
for (
let offset = 1;
offset <= 3 && index + offset < lines.length;
offset += 1
) {
const line = (lines[index + offset] ?? "").trim();
if (line.startsWith("HKEY_")) break;
const match = /REG_SZ\s+(.+)$/i.exec(line);
if (!match) continue;
const dir = (match[1] ?? "").trim();
if (!dir || /^\(.*\)$/.test(dir)) break;
const exe = /python[^\\/]*\.exe$/i.test(dir)
? dir
: `${dir.replace(/[\\/]+$/, "")}\\python.exe`;
found.push({ exe, source: `registry ${key}` });
break;
}
}
return found;
}
/** Parse `py -0p` lines: "-V:3.12 * D:\...\python.exe". */
export function parsePyListPaths(text: string): string[] {
const paths: string[] = [];
for (const line of text.split(/\r?\n/)) {
const match = /-V:\d+(?:\.\d+)?\s+\*?\s*(\S.*)$/.exec(line);
const exe = (match?.[1] ?? "").trim();
if (exe && /python[^\\/]*\.exe$/i.test(exe)) paths.push(exe);
}
return paths;
}
/** Parse `where`/`which -a` output into trimmed, non-empty lines. */
export function parsePathList(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 2);
}
/**
* Microsoft Store installs real interpreters under
* WindowsApps\PythonSoftwareFoundation.*, while the alias stubs sit directly
* in WindowsApps and only open the Store. Only the stubs must be dropped.
*/
export function isWindowsAppsAlias(exe: string): boolean {
const normalized = exe.replaceAll("/", "\\").toLowerCase();
const cut = normalized.lastIndexOf("\\");
if (cut < 0) return false;
const parent = normalized.slice(0, cut);
const parentName = parent.slice(parent.lastIndexOf("\\") + 1);
return parentName === "windowsapps";
}
function isWindowsStorePackage(exe: string): boolean {
return /\\windowsapps\\pythonsoftwarefoundation\./i.test(
exe.replaceAll("/", "\\"),
);
}
export function parseCondaEnvs(text: string): string[] {
try {
const value = JSON.parse(text) as { envs?: unknown };
if (!Array.isArray(value.envs)) return [];
return value.envs.filter(
(item): item is string => typeof item === "string" && item.length > 0,
);
} catch {
return [];
}
}
export function parseCondaRoot(text: string): string {
try {
const value = JSON.parse(text) as { root_prefix?: unknown };
return typeof value.root_prefix === "string" ? value.root_prefix : "";
} catch {
return "";
}
}
/* ------------------------------------------------------------------ */
/* Conda command runner: prefer the real conda.exe, fall back to .bat. */
/* ------------------------------------------------------------------ */
export type CondaRunner = (
args: string[],
timeoutMs?: number,
) => Promise<RunResult>;
/** Derive the conda installation root from any conda executable path. */
export function condaRootFromPath(condaPath: string): string {
const dir = dirname(condaPath);
const lower = dir.toLowerCase();
if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin"))
return dirname(dirname(dir));
if (lower.endsWith("\\scripts") || lower.endsWith("/scripts"))
return dirname(dir);
if (lower.endsWith("\\condabin") || lower.endsWith("/condabin"))
return dirname(dir);
if (lower.endsWith("\\bin") || lower.endsWith("/bin")) return dirname(dir);
return "";
}
/**
* Windows ships conda as .bat files that Node cannot spawn without a shell;
* run the real Scripts\conda.exe when present, otherwise wrap the .bat in
* cmd.exe with verbatim argv. The path comes from discovery, never user input.
*/
export function makeCondaRunner(
run: RunCommand,
condaPath: string,
windows: boolean,
exists: (path: string) => boolean,
): CondaRunner {
const root = condaRootFromPath(condaPath);
const exeCandidates: string[] = [];
if (root && windows) exeCandidates.push(join(root, "Scripts", "conda.exe"));
if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath);
return async (args, timeoutMs = 15_000) => {
for (const candidate of exeCandidates) {
if (!exists(candidate)) continue;
const result = await run(candidate, args, { timeoutMs });
if (!result.error) return result;
}
if (windows)
return run(
"cmd.exe",
["/d", "/s", "/c", `"${condaPath}" ${args.join(" ")}`],
{ timeoutMs, windowsVerbatimArguments: true },
);
return run(condaPath, args, { timeoutMs });
};
}
function commonCondaPaths(io: DetectIO, windows: boolean): string[] {
const programData = io.env["ProgramData"] ?? "";
const candidates = windows
? [
join(io.homedir, "miniconda3", "Scripts", "conda.exe"),
join(io.homedir, "anaconda3", "Scripts", "conda.exe"),
join(programData, "miniconda3", "Scripts", "conda.exe"),
join(programData, "anaconda3", "Scripts", "conda.exe"),
]
: [
join(io.homedir, "miniconda3", "bin", "conda"),
join(io.homedir, "anaconda3", "bin", "conda"),
join(io.homedir, "miniforge3", "bin", "conda"),
"/opt/miniconda3/bin/conda",
"/opt/anaconda3/bin/conda",
"/opt/homebrew/Caskroom/miniconda/base/bin/conda",
];
return candidates.filter((path) => isAbsolute(path));
}
/* ------------------------------------------------------------------ */
/* Orchestration. */
/* ------------------------------------------------------------------ */
interface Candidate {
exe: string;
sources: Set<string>;
}
function normalizeExe(exe: string, windows: boolean): string {
const trimmed = exe.trim();
if (!trimmed) return "";
// Unify separators for the simulated platform: resolve() follows the host,
// but detection must behave identically on every host.
const unified = windows
? trimmed.replaceAll("/", "\\")
: trimmed.replaceAll("\\", "/");
return windows ? unified.toLowerCase() : unified;
}
function addCandidate(
candidates: Map<string, Candidate>,
exe: string,
source: string,
windows: boolean,
): void {
const key = normalizeExe(exe, windows);
if (!key) return;
const existing = candidates.get(key);
if (existing) existing.sources.add(source);
else candidates.set(key, { exe: exe.trim(), sources: new Set([source]) });
}
function pathContains(dir: string, exe: string, windows: boolean): boolean {
const base = normalizeExe(resolve(dir), windows);
const full = normalizeExe(resolve(exe), windows);
return full.startsWith(`${base}${windows ? "\\" : "/"}`);
}
/** prefix of an interpreter install from its executable location. */
function pythonPrefix(exe: string, windows: boolean): string {
const parent = dirname(exe);
return windows || basename(parent) !== "bin" ? parent : dirname(parent);
}
function isVenv(info: VerifyInfo, windows: boolean): boolean {
if (!info.basePrefix) return false;
return (
normalizeExe(pythonPrefix(info.exe, windows), windows) !==
normalizeExe(info.basePrefix, windows)
);
}
const KIND_RANK: Record<PythonOrigin["kind"], number> = {
conda: 0,
"python.org": 1,
"windows-store": 2,
venv: 3,
system: 4,
unknown: 5,
};
function classifyOrigin(
info: VerifyInfo,
candidate: Candidate,
conda: { root: string; envs: string[] },
windows: boolean,
): PythonOrigin {
// Named envs live under the root too, so they must be matched first.
for (const envPath of conda.envs) {
if (envPath === conda.root) continue;
if (pathContains(envPath, info.exe, windows))
return { kind: "conda", envName: basename(envPath), root: envPath };
}
if (conda.root && pathContains(conda.root, info.exe, windows))
return { kind: "conda", envName: "base", root: conda.root };
if (isWindowsStorePackage(info.exe)) return { kind: "windows-store" };
if (isVenv(info, windows))
return {
kind: "venv",
baseExe: windows
? join(info.basePrefix, "python.exe")
: posixJoin(info.basePrefix, "bin", "python3"),
};
if ([...candidate.sources].some((source) => source.startsWith("registry")))
return { kind: "python.org" };
if (candidate.sources.has("PATH") || candidate.sources.has("which"))
return { kind: "system" };
return { kind: "unknown" };
}
function compareEnvs(a: PythonEnv, b: PythonEnv): number {
if (a.ok !== b.ok) return a.ok ? -1 : 1;
const rank = KIND_RANK[a.origin.kind] - KIND_RANK[b.origin.kind];
if (rank !== 0) return rank;
const nameA = a.origin.kind === "conda" ? a.origin.envName : "";
const nameB = b.origin.kind === "conda" ? b.origin.envName : "";
if (nameA !== nameB) return nameA.localeCompare(nameB);
return a.exe.localeCompare(b.exe);
}
/** Prefer a named conda env, then any usable interpreter. */
export function suggestInterpreter(envs: PythonEnv[]): string {
const usable = envs.filter((env) => env.ok);
const named = usable.find(
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
);
return (named ?? usable[0])?.exe ?? "";
}
async function verifyCandidates(
candidates: Map<string, Candidate>,
io: DetectIO,
): Promise<Map<string, VerifyInfo>> {
const verified = new Map<string, VerifyInfo>();
const queue = [...candidates.entries()];
const worker = async (): Promise<void> => {
for (;;) {
const entry = queue.shift();
if (!entry) return;
const [key, candidate] = entry;
let result: RunResult;
try {
result = await io.run(candidate.exe, ["-c", VERIFY_SNIPPET], {
timeoutMs: 8_000,
});
} catch {
continue;
}
if (result.error || result.code !== 0) continue;
const info = parseVerifyOutput(result.stdout);
if (!info) continue;
// Prefer the interpreter's own sys.executable (resolves symlinks/shims).
verified.set(key, { ...info, exe: info.exe || candidate.exe });
}
};
await Promise.all(
Array.from({ length: Math.min(6, queue.length) }, () => worker()),
);
return verified;
}
/**
* Discover local Python interpreters, verify each one by running it, and
* classify origins. Discovery failures are collected; they never reject.
*/
export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
const windows = io.platform === "win32";
const errors: string[] = [];
const candidates = new Map<string, Candidate>();
let condaRoot = "";
let condaEnvs: string[] = [];
const steps: Promise<void>[] = [
// PEP 514 registry (Windows; Miniconda registers itself here too).
(async () => {
if (!windows) return;
const hives = [
"HKCU\\Software\\Python",
"HKLM\\Software\\Python",
"HKLM\\Software\\WOW6432Node\\Python",
];
for (const hive of hives) {
const result = await io.run("reg.exe", ["query", hive, "/s", "/ve"], {
timeoutMs: 5_000,
});
if (result.error) continue;
for (const { exe, source } of parseRegQuery(result.stdout))
addCandidate(candidates, exe, source, windows);
}
})(),
// py launcher (Windows only; usually absent without a python.org install).
(async () => {
if (!windows) return;
const result = await io.run("py.exe", ["-0p"], { timeoutMs: 5_000 });
if (result.error) return;
for (const exe of parsePyListPaths(result.stdout))
addCandidate(candidates, exe, "py launcher", windows);
})(),
// PATH / which.
(async () => {
const first = windows
? await io.run("where.exe", ["python"], { timeoutMs: 5_000 })
: await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 });
let stdout = first.stdout;
if (!windows) {
const extra = await io.run("which", ["-a", "python"], {
timeoutMs: 5_000,
});
stdout = `${stdout}\n${extra.stdout}`;
}
for (const exe of parsePathList(stdout)) {
if (isWindowsAppsAlias(exe)) continue;
addCandidate(candidates, exe, windows ? "PATH" : "which", windows);
}
})(),
// conda: PATH first, then well-known install roots.
(async () => {
const located = windows
? parsePathList(
(await io.run("where.exe", ["conda"], { timeoutMs: 5_000 })).stdout,
)
: parsePathList(
(await io.run("which", ["-a", "conda"], { timeoutMs: 5_000 }))
.stdout,
);
for (const path of commonCondaPaths(io, windows))
if (io.exists(path)) located.push(path);
if (!located.length) return;
const runConda = makeCondaRunner(
io.run,
located[0] ?? "",
windows,
io.exists,
);
const info = await runConda(["info", "--json"]);
if (info.error)
errors.push(
`conda: discovered but not runnable (${safeError(info.error)})`,
);
condaRoot = info.error ? "" : parseCondaRoot(info.stdout);
const listing = await runConda(["env", "list", "--json"]);
condaEnvs = listing.error ? [] : parseCondaEnvs(listing.stdout);
if (!condaRoot && condaEnvs.length) condaRoot = condaEnvs[0] ?? "";
for (const envPath of condaEnvs) {
addCandidate(
candidates,
windows
? join(envPath, "python.exe")
: posixJoin(envPath, "bin", "python3"),
"conda env list",
windows,
);
}
})().catch((error: unknown) => {
errors.push(`conda: ${safeError(error)}`);
}),
];
await Promise.allSettled(steps);
const verified = await verifyCandidates(candidates, io);
// Candidates can resolve to the same real executable (aliases, shims);
// merge their discovery sources so the list stays duplicate-free.
const merged = new Map<string, PythonEnv>();
for (const [key, candidate] of candidates) {
const info = verified.get(key);
if (!info) continue;
const exe = info.exe || candidate.exe;
const dedupeKey = normalizeExe(exe, windows);
const existing = merged.get(dedupeKey);
if (existing) {
existing.source = [
...new Set([...existing.source.split("; "), ...candidate.sources]),
]
.sort()
.join("; ");
continue;
}
merged.set(dedupeKey, {
exe,
version: info.version,
ok: versionAtLeast(info.version),
origin: classifyOrigin(
info,
candidate,
{ root: condaRoot, envs: condaEnvs },
windows,
),
source: [...candidate.sources].sort().join("; "),
});
}
const envs = [...merged.values()];
envs.sort(compareEnvs);
return {
platform: io.platform,
scannedAt: new Date().toISOString(),
envs,
suggested: suggestInterpreter(envs),
condaRoot,
errors,
};
}

View file

@ -0,0 +1,75 @@
import { spawn } from "node:child_process";
export interface RunResult {
code: number | null;
stdout: string;
stderr: string;
/** Spawn or timeout failure description; absent on a normal exit. */
error?: string;
}
export interface RunOptions {
timeoutMs?: number;
cwd?: string;
/** Join argv verbatim (cmd.exe /s /c wrappers); never combine with user input. */
windowsVerbatimArguments?: boolean;
}
export type RunCommand = (
exe: string,
args: string[],
options?: RunOptions,
) => Promise<RunResult>;
/** UTF-8 guards so probe output survives GBK console codepages on Windows. */
export function probeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...base,
PYTHONIOENCODING: "utf-8",
PYTHONUTF8: "1",
};
}
/** Array-argv, shell-free runner with a hard timeout and no thrown rejections. */
export function createNodeRunner(
base: NodeJS.ProcessEnv = process.env,
): RunCommand {
return (exe, args, options = {}) =>
new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {
shell: false,
windowsHide: true,
...(options.windowsVerbatimArguments
? { windowsVerbatimArguments: true }
: {}),
...(options.cwd ? { cwd: options.cwd } : {}),
env: probeEnv(base),
});
} catch (error) {
resolve({ code: null, stdout: "", stderr: "", error: String(error) });
return;
}
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill();
}, options.timeoutMs ?? 10_000);
timer.unref();
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ code: null, stdout, stderr, error: String(error) });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}

View file

@ -0,0 +1,32 @@
/** A local Python interpreter discovered on this machine. */
export type PythonOrigin =
| { kind: "conda"; envName: string; root: string }
| { kind: "python.org" }
| { kind: "windows-store" }
| { kind: "venv"; baseExe: string }
| { kind: "system" }
| { kind: "unknown" };
export interface PythonEnv {
/** Verified interpreter executable (absolute, host-native separators). */
exe: string;
/** "major.minor.patch" reported by the interpreter; empty when unusable. */
version: string;
/** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */
ok: boolean;
origin: PythonOrigin;
/** Human-readable discovery channels, e.g. "conda env list; PATH". */
source: string;
}
export interface PythonDetection {
platform: string;
scannedAt: string;
envs: PythonEnv[];
/** Preferred interpreter for a local service, or "" when none is usable. */
suggested: string;
/** Conda installation root, or "" when conda was not found. */
condaRoot: string;
/** Non-fatal probe failures worth showing for transparency. */
errors: string[];
}

View file

@ -2,6 +2,8 @@ import type { ReMeClientConfig } from "./reme/types.js";
export interface ReMeConfigInput {
endpoint?: string;
authToken?: string;
pythonPath?: string;
requestTimeoutMs?: number;
backgroundTimeoutMs?: number;
shutdownTimeoutMs?: number;
@ -22,6 +24,8 @@ export interface ReMeConfigInput {
export interface ReMeConfig extends ReMeClientConfig {
shutdownTimeoutMs: number;
authToken: string;
pythonPath: string;
autoMemoryEnabled: boolean;
autoMemoryInterval: number;
autoMemoryQueueDir: string;

View file

@ -52,7 +52,7 @@ test("declares one installable DeepSeek Harness plugin", async () => {
}
assert.equal(manifest.peerDependencies.openclaw, undefined);
assert.match(patch, /remeMemory: true/);
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/);
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/);
assert.doesNotMatch(patch, /id: reme-memory-runtime/);
assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/);
assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1);

View file

@ -39,7 +39,7 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a
fiber: { state: 0 },
logger: { debug() {}, warn() {}, log() {} },
provide(name, value) {
assert.equal(name, "remeMemory");
assert.ok(name === "remeMemory" || name === "remeEnv");
assert.ok(value);
},
plugin() {

View file

@ -0,0 +1,516 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
condaRootFromPath,
detectPythonEnvs,
isWindowsAppsAlias,
makeCondaRunner,
parseCondaEnvs,
parseCondaRoot,
parsePathList,
parsePyListPaths,
parseRegQuery,
parseVerifyOutput,
suggestInterpreter,
versionAtLeast,
} from "../dist/pyenv/detect.js";
function fakeRun(rules) {
return async (exe, args) => {
for (const rule of rules) {
if (rule.match(exe, args))
return typeof rule.result === "function"
? rule.result(args)
: rule.result;
}
throw new Error(`unexpected command: ${exe} ${args.join(" ")}`);
};
}
const VERIFY_ARG0 = "-c";
function verifyRule(exe, version, base) {
return {
match: (actual, args) =>
actual.toLowerCase() === exe.toLowerCase() && args[0] === VERIFY_ARG0,
result: {
code: 0,
stdout: `note\n{"exe":"${exe.replaceAll(
"\\",
"\\\\",
)}","ver":"${version}","base":"${base.replaceAll("\\", "\\\\")}"}\n`,
stderr: "",
},
};
}
function windowsFixture() {
const miniconda = "D:\\ProgramFils\\Miniconda";
const remeServe = `${miniconda}\\envs\\reme-serve`;
const old38 = `${miniconda}\\envs\\old38`;
const storePackage =
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0";
const rules = [
{
match: (exe, args) =>
exe === "reg.exe" &&
args[0] === "query" &&
args[1] === "HKCU\\Software\\Python",
result: {
code: 0,
stdout: [
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12",
" (默认) REG_SZ 3.12",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath`,
` (默认) REG_SZ ${miniconda}\\`,
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12\\InstallPath`,
" (Default) REG_SZ C:\\Python312\\",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonLegacy\\3.8\\InstallPath`,
" (Default) REG_SZ C:\\Python38",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11",
"",
`HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11\\InstallPath`,
` (Default) REG_SZ ${storePackage}\\`,
"",
].join("\r\n"),
stderr: "",
},
},
{
match: (exe, args) => exe === "reg.exe" && args[0] === "query",
result: { code: 1, stdout: "", stderr: "key not found" },
},
{
match: (exe) => exe === "py.exe",
result: {
code: null,
stdout: "",
stderr: "",
error: "spawn py.exe ENOENT",
},
},
{
match: (exe, args) => exe === "where.exe" && args[0] === "python",
result: {
code: 0,
stdout: [
miniconda + "\\python.exe",
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe",
"",
].join("\r\n"),
stderr: "",
},
},
{
match: (exe, args) => exe === "where.exe" && args[0] === "conda",
result: {
code: 0,
stdout: `${miniconda}\\Library\\bin\\conda.bat\r\n`,
stderr: "",
},
},
{
match: (exe, args) =>
exe === `${miniconda}\\Scripts\\conda.exe` &&
args[0] === "info" &&
args[1] === "--json",
result: {
code: 0,
stdout: JSON.stringify({ root_prefix: miniconda }),
stderr: "",
},
},
{
match: (exe, args) =>
exe === `${miniconda}\\Scripts\\conda.exe` &&
args[0] === "env" &&
args[1] === "list",
result: {
code: 0,
stdout: JSON.stringify({
envs: [miniconda, remeServe, old38],
}),
stderr: "",
},
},
verifyRule(`${miniconda}\\python.exe`, "3.12.3", miniconda),
verifyRule(`${remeServe}\\python.exe`, "3.12.13", remeServe),
verifyRule(`${old38}\\python.exe`, "3.8.10", old38),
verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"),
verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"),
verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage),
];
return {
rules,
miniconda,
remeServe,
old38,
storePackage,
io: {
run: fakeRun(rules),
platform: "win32",
env: { USERPROFILE: "C:\\Users\\u", ProgramData: "C:\\ProgramData" },
homedir: "C:\\Users\\u",
exists: (path) => path === `${miniconda}\\Scripts\\conda.exe`,
},
};
}
test("parses PEP 514 registry output across locales, mojibake and empty values", () => {
const found = parseRegQuery(
[
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
" (\uFFFD\uFFFD) REG_SZ D:\\ProgramFils\\Miniconda\\",
"",
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.11\\InstallPath",
" (Default) REG_SZ C:\\Python311",
"",
"HKEY_CURRENT_USER\\Software\\Python\\Localized\\3.10\\InstallPath",
" (默认) REG_SZ C:\\Python310",
"",
"HKEY_CURRENT_USER\\Software\\Python\\Empty\\InstallPath",
" (\uFFFD\uFFFD) REG_SZ (\uFFFD\uFFFD\uFFFD\uFFFD)",
"",
].join("\r\n"),
);
assert.deepEqual(
found.map((entry) => entry.exe),
[
"D:\\ProgramFils\\Miniconda\\python.exe",
"C:\\Python311\\python.exe",
"C:\\Python310\\python.exe",
],
);
assert.equal(
found[0].source,
"registry HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
);
});
test("parses py launcher and path listings", () => {
assert.deepEqual(
parsePyListPaths(
[
" -V:3.12 * D:\\ProgramFils\\Miniconda\\python.exe",
" -V:3.11 C:\\Python311\\python.exe",
" -V:2.7 C:\\Python27\\python.exe",
].join("\n"),
),
[
"D:\\ProgramFils\\Miniconda\\python.exe",
"C:\\Python311\\python.exe",
"C:\\Python27\\python.exe",
],
);
assert.deepEqual(parsePathList("a\\python.exe\r\n\r\nb\\python.exe\n"), [
"a\\python.exe",
"b\\python.exe",
]);
});
test("drops Store alias stubs but keeps real Store interpreters", () => {
assert.equal(
isWindowsAppsAlias(
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python3.exe",
),
true,
);
assert.equal(
isWindowsAppsAlias(
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\\python.exe",
),
false,
);
assert.equal(isWindowsAppsAlias("C:\\Python312\\python.exe"), false);
});
test("compares interpreter versions numerically", () => {
assert.equal(versionAtLeast("3.12.3"), true);
assert.equal(versionAtLeast("3.11.0"), true);
assert.equal(versionAtLeast("3.12"), true);
assert.equal(versionAtLeast("3.10.8"), false);
assert.equal(versionAtLeast("3.8"), false);
assert.equal(versionAtLeast(""), false);
assert.equal(versionAtLeast("abc"), false);
});
test("parses interpreter verify output embedded in noisy stdout", () => {
const info = parseVerifyOutput(
'Warning: something\n{"exe":"D:\\\\x\\\\python.exe","ver":"3.12.3","base":"D:\\\\x"}\n',
);
assert.deepEqual(info, {
exe: "D:\\x\\python.exe",
version: "3.12.3",
basePrefix: "D:\\x",
});
assert.equal(parseVerifyOutput("no json here"), undefined);
});
test("parses conda json output defensively", () => {
assert.deepEqual(parseCondaEnvs('{"envs":["D:\\\\a","D:\\\\b"]}'), [
"D:\\a",
"D:\\b",
]);
assert.deepEqual(parseCondaEnvs("not json"), []);
assert.deepEqual(parseCondaEnvs('{"envs":42}'), []);
assert.equal(parseCondaRoot('{"root_prefix":"D:\\\\a"}'), "D:\\a");
assert.equal(parseCondaRoot("garbage"), "");
});
test("derives conda roots and prefers the real conda.exe over the .bat", async () => {
assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc");
assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc");
assert.equal(
condaRootFromPath("/home/u/miniconda3/bin/conda"),
"/home/u/miniconda3",
);
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
const calls = [];
const run = async (exe, args) => {
calls.push([exe, args]);
return { code: 0, stdout: "{}", stderr: "" };
};
const runner = makeCondaRunner(
run,
"D:\\mc\\Library\\bin\\conda.bat",
true,
(path) => path === "D:\\mc\\Scripts\\conda.exe",
);
await runner(["info", "--json"]);
assert.deepEqual(calls[0][0], "D:\\mc\\Scripts\\conda.exe");
const fallback = [];
const runner2 = makeCondaRunner(
async (exe, args) => {
fallback.push([exe, args]);
return { code: null, stdout: "", stderr: "", error: "spawn ENOENT" };
},
"D:\\mc\\Library\\bin\\conda.bat",
true,
() => false,
);
await runner2(["env", "list", "--json"]);
assert.equal(fallback.at(-1)[0], "cmd.exe");
assert.ok(fallback.at(-1)[1].includes("/s"));
});
test("detects, classifies and suggests Windows interpreters end to end", async () => {
const { io, miniconda, remeServe, old38, storePackage } = windowsFixture();
const detection = await detectPythonEnvs(io);
assert.equal(detection.platform, "win32");
assert.equal(detection.condaRoot, miniconda);
assert.deepEqual(detection.errors, []);
assert.equal(detection.suggested, `${remeServe}\\python.exe`);
const exes = detection.envs.map((env) => env.exe.toLowerCase());
assert.ok(!exes.some((exe) => exe.includes("windowsapps\\\\python.exe")));
assert.ok(
!exes.some(
(exe) =>
exe ===
"c:\\users\\u\\appdata\\local\\microsoft\\windowsapps\\python.exe",
),
);
assert.equal(detection.envs.length, 6);
const base = detection.envs.find(
(env) => env.origin.kind === "conda" && env.origin.envName === "base",
);
assert.ok(base);
assert.equal(base.exe, `${miniconda}\\python.exe`);
assert.equal(base.version, "3.12.3");
assert.equal(base.ok, true);
assert.equal(base.origin.root, miniconda);
assert.ok(base.source.includes("PATH"));
assert.ok(base.source.includes("conda env list"));
assert.ok(base.source.includes("registry"));
const named = detection.envs.find(
(env) => env.exe === `${remeServe}\\python.exe`,
);
assert.ok(named);
assert.equal(named.origin.kind, "conda");
assert.equal(named.origin.envName, "reme-serve");
const legacy = detection.envs.find(
(env) => env.exe === `${old38}\\python.exe`,
);
assert.ok(legacy);
assert.equal(legacy.ok, false);
assert.ok(detection.envs.indexOf(legacy) > detection.envs.indexOf(base));
const pythonOrg = detection.envs.find(
(env) => env.exe === "C:\\Python312\\python.exe",
);
assert.ok(pythonOrg);
assert.equal(pythonOrg.origin.kind, "python.org");
const legacy38 = detection.envs.find(
(env) => env.exe === "C:\\Python38\\python.exe",
);
assert.ok(legacy38);
assert.equal(legacy38.ok, false);
const store = detection.envs.find(
(env) => env.exe === `${storePackage}\\python.exe`,
);
assert.ok(store);
assert.equal(store.origin.kind, "windows-store");
assert.equal(store.ok, true);
});
test("suggests nothing usable by preferring named conda envs then any usable interpreter", () => {
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "base", root: "r" },
source: "",
},
{
exe: "b",
version: "3.12.0",
ok: true,
origin: { kind: "conda", envName: "reme", root: "r" },
source: "",
},
]),
"b",
);
assert.equal(
suggestInterpreter([
{
exe: "a",
version: "3.8.0",
ok: false,
origin: { kind: "conda", envName: "base", root: "r" },
source: "",
},
{
exe: "c",
version: "3.11.9",
ok: true,
origin: { kind: "python.org" },
source: "",
},
]),
"c",
);
assert.equal(suggestInterpreter([]), "");
});
test("detects Unix interpreters and merges paths that resolve together", async () => {
const rules = [
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "python3",
result: { code: 0, stdout: "/usr/bin/python3\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "python",
result: { code: 0, stdout: "/usr/bin/python\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "which" && args[0] === "-a" && args[1] === "conda",
result: { code: 0, stdout: "/home/u/miniconda3/bin/conda\n", stderr: "" },
},
{
match: (exe, args) =>
exe === "/home/u/miniconda3/bin/conda" && args[0] === "info",
result: {
code: 0,
stdout: JSON.stringify({ root_prefix: "/home/u/miniconda3" }),
stderr: "",
},
},
{
match: (exe, args) =>
exe === "/home/u/miniconda3/bin/conda" && args[0] === "env",
result: {
code: 0,
stdout: JSON.stringify({
envs: ["/home/u/miniconda3", "/home/u/miniconda3/envs/reme"],
}),
stderr: "",
},
},
verifyRule("/usr/bin/python3", "3.10.12", "/usr"),
// /usr/bin/python is a shim whose sys.executable reports python3.
{
match: (actual, args) =>
actual === "/usr/bin/python" && args[0] === VERIFY_ARG0,
result: {
code: 0,
stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n',
stderr: "",
},
},
verifyRule(
"/home/u/miniconda3/bin/python3",
"3.12.1",
"/home/u/miniconda3",
),
verifyRule(
"/home/u/miniconda3/envs/reme/bin/python3",
"3.11.9",
"/home/u/miniconda3/envs/reme",
),
];
const detection = await detectPythonEnvs({
run: fakeRun(rules),
platform: "linux",
env: {},
homedir: "/home/u",
exists: () => false,
});
assert.equal(detection.platform, "linux");
assert.equal(detection.condaRoot, "/home/u/miniconda3");
assert.equal(detection.suggested, "/home/u/miniconda3/envs/reme/bin/python3");
const system = detection.envs.filter((env) => env.origin.kind === "system");
assert.equal(system.length, 1);
assert.equal(system[0].exe, "/usr/bin/python3");
assert.equal(system[0].ok, false);
const named = detection.envs.find((env) => env.origin.envName === "reme");
assert.ok(named);
assert.equal(named.ok, true);
assert.equal(named.version, "3.11.9");
});
test("collects conda failures without rejecting the whole detection", async () => {
const { io, miniconda } = windowsFixture();
io.run = async (exe, args) => {
if (exe === `${miniconda}\\Scripts\\conda.exe`)
return { code: null, stdout: "", stderr: "", error: "spawn failed" };
const original = windowsFixture();
const rule = original.rules.find((entry) => entry.match(exe, args));
if (!rule) throw new Error(`unexpected: ${exe} ${args.join(" ")}`);
return typeof rule.result === "function" ? rule.result(args) : rule.result;
};
const detection = await detectPythonEnvs(io);
assert.equal(detection.condaRoot, "");
assert.ok(detection.errors.some((error) => error.startsWith("conda:")));
assert.ok(
detection.envs.some((env) => env.exe === `${miniconda}\\python.exe`),
);
});