diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx new file mode 100644 index 00000000..68126d61 --- /dev/null +++ b/integrations/dsh/src/client/env-tab.tsx @@ -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; + 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({ + phase: "loading", + }); + const [health, setHealth] = useState({ + 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 ( +
+
+

{t("connectionTitle")}

+
+ {t("endpointLabel")} + {endpoint} + {!healthCurrent ? ( + + + {t("checking")} + + ) : health.phase === "ok" ? ( + + + {health.version ? `ReMe ${health.version}` : t("connected")} + + ) : ( + + + {t("disconnected")} + + )} + +
+ {healthCurrent && health.phase === "fail" && health.error ? ( +

{health.error}

+ ) : null} +
+ {!local ? ( +

+ {t("remoteNotice")} +

+ ) : ( + <> +
+

{t("pythonTitle")}

+

{t("pythonHint")}

+
+ {detection.phase === "loading" ? ( + {t("detecting")} + ) : null} + +
+ {detection.phase === "error" ? ( +

+ {t("detectFailed")} + {detection.error ? `: ${detection.error}` : ""} +

+ ) : null} + {detected && detected.envs.length === 0 ? ( +
+ {t("emptyTitle")} + {t("emptyHint")} + + {t("miniconda")} + +
+ ) : null} + {detected && detected.envs.length > 0 ? ( +
+ {detected.envs.map((env) => ( + + ))} +
+ ) : null} + {detected && detected.errors.length > 0 ? ( +
+ {t("errorsTitle")} +
{detected.errors.join("\n")}
+
+ ) : null} +
+
+

{t("dependencyTitle")}

+

+ {t("upcoming")} +

+
+
+

{t("configTitle")}

+

+ {t("upcoming")} +

+
+
+

{t("controlTitle")}

+

+ {t("upcoming")} +

+
+ + )} +
+ ); +} diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index d14a0151..e73931f2 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -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; 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(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 = (field: K, next: Draft[K]) => { if (value === undefined) return; @@ -373,79 +413,115 @@ function ReMeSettingsCard({ return (
- {dirty ?

{t("unsaved")}

: null} - {snapshot.status === "loading" || - draft === undefined || - value === undefined ? ( -

{t("loading")}

+
+ + +
+ {tab === "environment" ? ( + rpc && envT ? ( + + ) : ( +

{t("loading")}

+ ) ) : ( <> -
- -
- {!snapshot.writable ? ( -

- {t("readOnly")} -

+ {dirty ? ( +

{t("unsaved")}

) : null} - {validation === undefined ? ( -

- {t("invalid")} -

- ) : null} -
- - - {saveState === "failed" ? ( - {t("saveFailed")} + {snapshot.status === "loading" || + draft === undefined || + value === undefined ? ( +

{t("loading")}

+ ) : ( + <> +
+ +
+ {!snapshot.writable ? ( +

+ {t("readOnly")} +

) : null} - {saveState === "saved" ? ( - {t("saved")} + {validation === undefined ? ( +

+ {t("invalid")} +

) : null} -
- - -
+
+ + + {saveState === "failed" ? ( + + {t("saveFailed")} + + ) : null} + {saveState === "saved" ? ( + + {t("saved")} + + ) : null} + + + +
+ + )} )}
@@ -677,7 +753,10 @@ function draftFrom(value: ReMeSettings): Draft { }; } -function parseDraft(draft: Draft): ReMeSettings | undefined { +function parseDraft( + draft: Draft, + passthrough?: Pick, +): 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!, diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index f58f1cc1..347ca68f 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -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}} `; diff --git a/integrations/dsh/src/config.ts b/integrations/dsh/src/config.ts index e517814c..f402df4a 100644 --- a/integrations/dsh/src/config.ts +++ b/integrations/dsh/src/config.ts @@ -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 = 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( diff --git a/integrations/dsh/src/env-gateway.ts b/integrations/dsh/src/env-gateway.ts new file mode 100644 index 00000000..34843186 --- /dev/null +++ b/integrations/dsh/src/env-gateway.ts @@ -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 { + return this.ctx.remeEnv.detectPython(input); + } +} + +export default ReMeEnvGateway; diff --git a/integrations/dsh/src/env-manager.ts b/integrations/dsh/src/env-manager.ts new file mode 100644 index 00000000..9d106ed7 --- /dev/null +++ b/integrations/dsh/src/env-manager.ts @@ -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 { + 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; + } +} diff --git a/integrations/dsh/src/index.ts b/integrations/dsh/src/index.ts index 8a14eebd..a0eb7d82 100644 --- a/integrations/dsh/src/index.ts +++ b/integrations/dsh/src/index.ts @@ -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()", diff --git a/integrations/dsh/src/pyenv/detect.ts b/integrations/dsh/src/pyenv/detect.ts new file mode 100644 index 00000000..b5ef88d8 --- /dev/null +++ b/integrations/dsh/src/pyenv/detect.ts @@ -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; + 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 /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; + +/** 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; +} + +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, + 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 = { + 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, + io: DetectIO, +): Promise> { + const verified = new Map(); + const queue = [...candidates.entries()]; + const worker = async (): Promise => { + 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 { + const windows = io.platform === "win32"; + const errors: string[] = []; + const candidates = new Map(); + let condaRoot = ""; + let condaEnvs: string[] = []; + + const steps: Promise[] = [ + // 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(); + 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, + }; +} diff --git a/integrations/dsh/src/pyenv/runner.ts b/integrations/dsh/src/pyenv/runner.ts new file mode 100644 index 00000000..57346a8a --- /dev/null +++ b/integrations/dsh/src/pyenv/runner.ts @@ -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; + +/** 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; + 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 }); + }); + }); +} diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts new file mode 100644 index 00000000..74910e97 --- /dev/null +++ b/integrations/dsh/src/pyenv/types.ts @@ -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[]; +} diff --git a/integrations/dsh/src/types.ts b/integrations/dsh/src/types.ts index 13e870e4..6cb0a468 100644 --- a/integrations/dsh/src/types.ts +++ b/integrations/dsh/src/types.ts @@ -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; diff --git a/integrations/dsh/tests/bundle.test.mjs b/integrations/dsh/tests/bundle.test.mjs index 39bd23ec..63175b42 100644 --- a/integrations/dsh/tests/bundle.test.mjs +++ b/integrations/dsh/tests/bundle.test.mjs @@ -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); diff --git a/integrations/dsh/tests/dsh-index.test.mjs b/integrations/dsh/tests/dsh-index.test.mjs index f19b3476..1e74147f 100644 --- a/integrations/dsh/tests/dsh-index.test.mjs +++ b/integrations/dsh/tests/dsh-index.test.mjs @@ -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() { diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs new file mode 100644 index 00000000..5cb1ccb2 --- /dev/null +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -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`), + ); +});