openclaw-memory-decay 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,14 +51,17 @@ Activation
51
51
  - **Proactive agent saves** — the agent stores preferences, decisions, facts, and episodes without being asked
52
52
  - **Freshness indicators** — search results include `fresh` / `normal` / `stale` so the agent can judge reliability
53
53
  - **Dual-score model** — storage score (can it be found?) and retrieval score (how easily?) are tracked separately
54
- - **`/remember` skill** — users can explicitly ask the agent to remember something
54
+ - **Memory tools** — the plugin exposes `memory_search`, `memory_store`, and `memory_store_batch`
55
55
  - **Markdown migration** — imports existing `~/.openclaw/workspace/memory/` files on first run
56
56
 
57
57
  ## Quick Start
58
58
 
59
+ > Deprecated: installing from a cloned checkout via `openclaw plugins install -l .` is no longer the recommended path. Use the published npm package instead.
60
+
59
61
  ```bash
60
- # 1. Install memory-decay-core (the backend engine)
61
- pip install memory-decay
62
+ # 1. Install the backend into a dedicated virtualenv
63
+ python3 -m venv ~/.openclaw/venvs/memory-decay
64
+ ~/.openclaw/venvs/memory-decay/bin/pip install memory-decay
62
65
 
63
66
  # 2. Install this plugin from npm
64
67
  openclaw plugins install openclaw-memory-decay
@@ -70,13 +73,44 @@ openclaw config set plugins.allow '["memory-decay"]'
70
73
  openclaw gateway restart
71
74
  ```
72
75
 
73
- > **Note:** Steps 5 and 6 both succeed silently if the plugin is already loaded use `openclaw plugins list` to confirm status is `loaded` and origin is `config`.
76
+ > **Note:** If the plugin is already loaded, install may succeed silently. Use `openclaw plugins list` to confirm status is `loaded`.
74
77
 
75
78
  ### Prerequisites
76
79
 
77
80
  - [OpenClaw](https://openclaw.ai) installed globally
78
81
  - Python 3.10+
79
- - `memory-decay` Python package (`pip install memory-decay`)
82
+ - A Python virtualenv containing `memory-decay`
83
+
84
+ ## Migration From Deprecated Local Install
85
+
86
+ If you previously installed this plugin from a local checkout via `openclaw plugins install -l .`, migrate to the npm package. Local/path-based installs are now deprecated in favor of the published npm package.
87
+
88
+ ```bash
89
+ # 1. Install from npm instead
90
+ openclaw plugins install openclaw-memory-decay
91
+
92
+ # 2. Restart gateway
93
+ openclaw gateway restart
94
+
95
+ # 3. Verify
96
+ openclaw plugins list | grep memory-decay # should show: memory-decay | loaded
97
+ curl -s http://127.0.0.1:8100/health # should show: {"status":"ok","current_tick":0}
98
+ ```
99
+
100
+ If auto-detection does not recover your backend path after migration, set the interpreter explicitly:
101
+
102
+ ```bash
103
+ openclaw config set plugins.entries.memory-decay.config.pythonPath "~/.openclaw/venvs/memory-decay/bin/python"
104
+ openclaw gateway restart
105
+ ```
106
+
107
+ **Your memories are safe.** The SQLite database (`memories.db`) is not affected by plugin reinstallation or migration to npm install.
108
+
109
+ To update in the future:
110
+ ```bash
111
+ openclaw plugins update memory-decay
112
+ openclaw gateway restart
113
+ ```
80
114
 
81
115
  ## Configuration
82
116
 
@@ -102,8 +136,8 @@ Add to `~/.openclaw/openclaw.json` under `plugins.entries.memory-decay.config`:
102
136
  | Option | Default | Description |
103
137
  |--------|---------|-------------|
104
138
  | `serverPort` | `8100` | Port for the memory-decay HTTP server |
105
- | `memoryDecayPath` | (auto) | Path to memory-decay-core. Auto-detected from `pip show memory-decay` if not set |
106
- | `pythonPath` | `python3` | Path to Python interpreter (use your venv) |
139
+ | `memoryDecayPath` | (auto) | Path to memory-decay-core. Auto-detected from the install-time Python environment if not set |
140
+ | `pythonPath` | `python3` | Path to Python interpreter. Set this explicitly if you use a custom venv path instead of `~/.openclaw/venvs/memory-decay` |
107
141
  | `dbPath` | `~/.openclaw/memory-decay-data/memories.db` | SQLite database location |
108
142
  | `autoSave` | `true` | Auto-save every conversation turn at low importance. Set `false` to let the agent decide what to save |
109
143
  | `embeddingProvider` | `local` | Embedding provider: `local`, `openai`, or `gemini` |
@@ -149,7 +183,7 @@ The bootstrap prompt and skills guide the agent to pick the right category and i
149
183
  | `fact` | Technical facts, API behaviors, architecture | 0.7–0.9 | "Auth service returns inconsistent 4xx on token expiry" |
150
184
  | `episode` | What was worked on, session context | 0.3–0.6 | "Finished migrating auth middleware" |
151
185
 
152
- The agent stores proactively based on conversation triggers — it doesn't wait for `/remember`.
186
+ The agent stores proactively based on conversation triggers — it doesn't wait to be explicitly asked.
153
187
 
154
188
  ## How It Works
155
189
 
@@ -192,17 +226,15 @@ The plugin manages the Python server lifecycle — starts with the gateway, stop
192
226
  4. **Reinforce** — recalled memories get boosted (testing effect), stability grows
193
227
  5. **Forget** — memories with very low activation become practically unretrievable
194
228
 
195
- ## Skills
229
+ ## Tools
196
230
 
197
- The plugin registers these skills:
231
+ The plugin registers these tools:
198
232
 
199
- | Skill | Trigger | Description |
200
- |-------|---------|-------------|
201
- | `/remember` | `/remember I prefer dark mode` | Save with correct category and calibrated importance |
202
- | `/recall` | `/recall what did we decide about the API?` | Search memories with freshness-aware action guidance |
203
- | `/forget` | `/forget the temp password` | Delete a specific memory |
204
- | `/memory-status` | `/memory-status` | Show memory count, tick, and decay stats |
205
- | `/migrate` | `/migrate` | Import Markdown files from `memory/` directory |
233
+ | Tool | Purpose |
234
+ |------|---------|
235
+ | `memory_search` | Search memories with decay-aware ranking and freshness indicators |
236
+ | `memory_store` | Save a single memory with calibrated importance and category |
237
+ | `memory_store_batch` | Save multiple memories efficiently in one call |
206
238
 
207
239
  ## Troubleshooting
208
240
 
@@ -224,16 +256,26 @@ openclaw plugins install openclaw-memory-decay
224
256
  ### `Memory service not running`
225
257
 
226
258
  ```bash
227
- # Check if memory-decay is installed
228
- pip show memory-decay
259
+ # Check if the configured Python can import the server
260
+ ~/.openclaw/venvs/memory-decay/bin/python -c "import memory_decay.server; print('ok')"
229
261
 
230
262
  # Check server health
231
263
  curl http://127.0.0.1:8100/health
232
264
  ```
233
265
 
234
- If `pip show memory-decay` returns nothing, install it:
266
+ If the import check fails, install the backend into a virtualenv and either reactivate that venv before reinstalling the plugin or set `pythonPath` explicitly:
235
267
  ```bash
236
- pip install memory-decay
268
+ python3 -m venv ~/.openclaw/venvs/memory-decay
269
+ ~/.openclaw/venvs/memory-decay/bin/pip install memory-decay
270
+ ```
271
+
272
+ ### `error: externally-managed-environment`
273
+
274
+ Your system Python is PEP 668 managed. Install `memory-decay` into a virtualenv instead of the system interpreter:
275
+
276
+ ```bash
277
+ python3 -m venv ~/.openclaw/venvs/memory-decay
278
+ ~/.openclaw/venvs/memory-decay/bin/pip install memory-decay
237
279
  ```
238
280
 
239
281
  ### Plugin shows `error` status
@@ -245,27 +287,26 @@ openclaw plugins doctor
245
287
  ## For AI Agents: Self-Installation Guide
246
288
 
247
289
  > This section is written for LLM agents to follow autonomously. If you're a human, use the [Quick Start](#quick-start) section above.
290
+ > Use the published npm package. Do not install this plugin from a cloned checkout unless you are actively developing it.
248
291
 
249
292
  ### Quick Install (one-shot)
250
293
 
251
294
  ```bash
252
- # 1. Install the backend engine (requires Python 3.10+)
253
- pip install memory-decay
254
-
255
- # 2. Verify
256
- pip show memory-decay | head -3
295
+ # 1. Install the backend into a dedicated virtualenv
296
+ python3 -m venv ~/.openclaw/venvs/memory-decay
297
+ ~/.openclaw/venvs/memory-decay/bin/pip install memory-decay
257
298
 
258
- # 3. Install plugin from npm
299
+ # 2. Install plugin from npm
259
300
  openclaw plugins install openclaw-memory-decay
260
301
 
261
- # 4. Restart gateway
302
+ # 3. Restart gateway
262
303
  openclaw gateway restart
263
304
 
264
- # 5. Verify plugin is loaded
305
+ # 4. Verify plugin is loaded
265
306
  openclaw plugins list
266
307
  # Look for: memory-decay | loaded
267
308
 
268
- # 6. Check server health
309
+ # 5. Check server health
269
310
  curl -s http://127.0.0.1:8100/health
270
311
  # Expected: {"status":"ok","current_tick":0}
271
312
  ```
@@ -285,14 +326,14 @@ openclaw plugins install openclaw-memory-decay
285
326
 
286
327
  **`Memory service not running`:**
287
328
  ```bash
288
- # Check backend is installed
289
- pip show memory-decay
329
+ # Check the configured Python can import the backend server
330
+ ~/.openclaw/venvs/memory-decay/bin/python -c "import memory_decay.server; print('ok')"
290
331
 
291
332
  # Check if gateway started it
292
333
  curl -s http://127.0.0.1:8100/health
293
334
 
294
- # If pip show returns nothing:
295
- pip install memory-decay
335
+ # If the import fails, reinstall into the venv:
336
+ ~/.openclaw/venvs/memory-decay/bin/pip install memory-decay
296
337
  openclaw gateway restart
297
338
  ```
298
339
 
@@ -2,7 +2,7 @@
2
2
  "id": "memory-decay",
3
3
  "name": "Memory Decay",
4
4
  "description": "Human-like memory with decay and reinforcement for OpenClaw agents",
5
- "version": "0.1.4",
5
+ "version": "0.1.7",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-memory-decay",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "OpenClaw memory plugin backed by memory-decay engine",
6
6
  "main": "./src/index.js",
@@ -41,5 +41,8 @@
41
41
  "repository": {
42
42
  "type": "git",
43
43
  "url": "https://github.com/memory-decay/openclaw-memory-decay"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
44
47
  }
45
48
  }
@@ -0,0 +1,90 @@
1
+ import { execFileSync as nodeExecFileSync } from "node:child_process";
2
+ import { existsSync as nodeExistsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { homedir as nodeHomedir, tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+
6
+ function resolvePythonPath(command, { execFileSync = nodeExecFileSync, isWin = process.platform === "win32" } = {}) {
7
+ if (isAbsolute(command)) {
8
+ return command;
9
+ }
10
+
11
+ const resolver = isWin ? "where" : "which";
12
+ return execFileSync(resolver, [command], { encoding: "utf8" }).trim().split(/\r?\n/)[0];
13
+ }
14
+
15
+ export function resolveMemoryDecayPath(moduleFile) {
16
+ const packageDir = dirname(resolve(moduleFile));
17
+ const packageParent = dirname(packageDir);
18
+ return basename(packageParent) === "src" ? dirname(packageParent) : packageParent;
19
+ }
20
+
21
+ export function buildPythonCandidates({
22
+ pluginRoot,
23
+ env = process.env,
24
+ isWin = process.platform === "win32",
25
+ existsSync = nodeExistsSync,
26
+ homedir = nodeHomedir,
27
+ } = {}) {
28
+ const openclawStateDir = resolve(env.OPENCLAW_HOME || homedir(), ".openclaw");
29
+ const siblingRoots = [
30
+ resolve(pluginRoot, "../memory-decay"),
31
+ resolve(pluginRoot, "../memory-decay-core"),
32
+ ];
33
+ const pathCandidates = [];
34
+
35
+ if (env.MD_PYTHON_PATH) pathCandidates.push(env.MD_PYTHON_PATH);
36
+ pathCandidates.push(
37
+ join(openclawStateDir, "venvs", "memory-decay", isWin ? "Scripts/python.exe" : "bin/python"),
38
+ );
39
+ if (env.VIRTUAL_ENV) {
40
+ pathCandidates.push(join(env.VIRTUAL_ENV, isWin ? "Scripts/python.exe" : "bin/python"));
41
+ }
42
+ for (const root of siblingRoots) {
43
+ pathCandidates.push(join(root, isWin ? ".venv/Scripts/python.exe" : ".venv/bin/python"));
44
+ }
45
+
46
+ const commandCandidates = isWin ? ["python"] : ["python3", "python"];
47
+ return [...new Set([
48
+ ...pathCandidates.filter((candidate) => existsSync(candidate)),
49
+ ...commandCandidates,
50
+ ])];
51
+ }
52
+
53
+ export function detectPythonEnv({
54
+ pluginRoot,
55
+ env = process.env,
56
+ isWin = process.platform === "win32",
57
+ existsSync = nodeExistsSync,
58
+ execFileSync = nodeExecFileSync,
59
+ makeTempDir = () => mkdtempSync(join(tmpdir(), "memory-decay-python-env-")),
60
+ readPathFile = (path) => readFileSync(path, "utf8"),
61
+ removeTempDir = (path) => rmSync(path, { recursive: true, force: true }),
62
+ } = {}) {
63
+ for (const candidate of buildPythonCandidates({ pluginRoot, env, isWin, existsSync })) {
64
+ try {
65
+ execFileSync(candidate, ["-c", "import memory_decay.server"], { stdio: "ignore" });
66
+ const tempDir = makeTempDir();
67
+ const outputPath = join(tempDir, "memory-decay-module-path.txt");
68
+
69
+ try {
70
+ execFileSync(candidate, [
71
+ "-c",
72
+ `import memory_decay, pathlib; pathlib.Path(${JSON.stringify(outputPath)}).write_text(str(pathlib.Path(memory_decay.__file__).resolve()))`,
73
+ ], { stdio: "ignore" });
74
+ } catch (error) {
75
+ removeTempDir(tempDir);
76
+ throw error;
77
+ }
78
+
79
+ const moduleFile = readPathFile(outputPath).trim();
80
+ removeTempDir(tempDir);
81
+
82
+ return {
83
+ pythonPath: resolvePythonPath(candidate, { execFileSync, isWin }),
84
+ memoryDecayPath: resolveMemoryDecayPath(moduleFile),
85
+ };
86
+ } catch {}
87
+ }
88
+
89
+ return null;
90
+ }
@@ -1,35 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { execSync } from "node:child_process";
3
2
  import { writeFileSync } from "node:fs";
4
3
  import { resolve, dirname } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
5
+ import { detectPythonEnv } from "./detect-python-lib.mjs";
6
6
 
7
7
  const root = dirname(fileURLToPath(import.meta.url));
8
- const isWin = process.platform === "win32";
9
- const candidates = isWin ? ["python"] : ["python3", "python"];
8
+ const detected = detectPythonEnv({ pluginRoot: resolve(root, "..") });
10
9
 
11
- const python = candidates.find((py) => {
12
- try {
13
- execSync(
14
- `${py} -c "import memory_decay; import sqlite3; sqlite3.connect(':memory:').enable_load_extension(True)"`,
15
- { stdio: "ignore" },
16
- );
17
- return true;
18
- } catch {
19
- return false;
20
- }
21
- });
22
-
23
- if (!python) {
24
- console.warn("[memory-decay] memory_decay not found — run: pip install memory-decay");
10
+ if (!detected) {
11
+ console.warn(
12
+ "[memory-decay] memory_decay server not found in an active or sibling Python environment. " +
13
+ "Install the backend in a venv and rerun install, or set pythonPath in plugin config.",
14
+ );
25
15
  process.exit(0);
26
16
  }
27
17
 
28
- const pythonPath = execSync(isWin ? `where ${python}` : `which ${python}`, { encoding: "utf8" }).trim().split("\n")[0];
29
- const memoryDecayPath = execSync(
30
- `${python} -c "import memory_decay,os; print(os.path.dirname(os.path.dirname(memory_decay.__file__)))"`,
31
- { encoding: "utf8" }
32
- ).trim();
33
-
34
- writeFileSync(resolve(root, "../.python-env.json"), JSON.stringify({ pythonPath, memoryDecayPath }, null, 2));
35
- console.log(`[memory-decay] Detected: ${pythonPath}`);
18
+ writeFileSync(resolve(root, "../.python-env.json"), JSON.stringify(detected, null, 2));
19
+ console.log(`[memory-decay] Detected: ${detected.pythonPath}`);
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
3
  import { MemoryDecayClient } from "./client.js";
4
4
  import { MemoryDecayService, type ServiceConfig } from "./service.js";
5
5
  import { shouldMigrate, migrateMarkdownMemories } from "./migrator.js";
6
+ import { detectPythonEnv, mergePythonEnv } from "./python-env.js";
6
7
  import { toFreshness } from "./types.js";
7
8
 
8
9
  const BOOTSTRAP_PROMPT = `## Memory System (memory-decay)
@@ -88,21 +89,30 @@ const memoryDecayPlugin = {
88
89
  // Resolve pythonPath + memoryDecayPath: config > .python-env.json (set at install time) > error
89
90
  let memoryDecayPath = (cfg.memoryDecayPath as string) ?? "";
90
91
  let pythonPath = (cfg.pythonPath as string) ?? "";
91
- if (!memoryDecayPath) {
92
+ let detectedEnv: { memoryDecayPath?: string; pythonPath?: string } = {};
93
+ const { dirname, resolve } = await import("node:path");
94
+ const { fileURLToPath } = await import("node:url");
95
+ const pluginRoot = dirname(fileURLToPath(import.meta.url));
96
+ if (!memoryDecayPath || !pythonPath) {
92
97
  try {
93
98
  const { readFileSync } = await import("node:fs");
94
- const { resolve, dirname } = await import("node:path");
95
- const { fileURLToPath } = await import("node:url");
96
- const pluginRoot = dirname(fileURLToPath(import.meta.url));
97
- const detected = JSON.parse(readFileSync(resolve(pluginRoot, "../.python-env.json"), "utf8"));
98
- if (!pythonPath && detected.pythonPath) pythonPath = detected.pythonPath;
99
- if (detected.memoryDecayPath) memoryDecayPath = detected.memoryDecayPath;
99
+ detectedEnv = JSON.parse(readFileSync(resolve(pluginRoot, "../.python-env.json"), "utf8"));
100
100
  } catch {}
101
101
  }
102
+ if (!detectedEnv.memoryDecayPath || !detectedEnv.pythonPath) {
103
+ detectedEnv = mergePythonEnv(
104
+ detectedEnv,
105
+ detectPythonEnv({ pluginRoot }) ?? {},
106
+ );
107
+ }
108
+ ({ memoryDecayPath, pythonPath } = mergePythonEnv(
109
+ { memoryDecayPath, pythonPath },
110
+ detectedEnv,
111
+ ));
102
112
  if (!memoryDecayPath) {
103
113
  ctx.logger.error(
104
114
  "Could not auto-detect memory-decay installation. " +
105
- "Run `pip install memory-decay` or set memoryDecayPath in plugin config."
115
+ "Install the backend into ~/.openclaw/venvs/memory-decay or set pythonPath/memoryDecayPath in plugin config."
106
116
  );
107
117
  return;
108
118
  }
@@ -0,0 +1,133 @@
1
+ import { execFileSync as nodeExecFileSync } from "node:child_process";
2
+ import { existsSync as nodeExistsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { homedir as nodeHomedir, tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+
6
+ export interface PythonEnvLike {
7
+ memoryDecayPath?: string;
8
+ pythonPath?: string;
9
+ }
10
+
11
+ interface DetectPythonEnvOptions {
12
+ pluginRoot: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ isWin?: boolean;
15
+ homedir?: () => string;
16
+ existsSync?: (path: string) => boolean;
17
+ execFileSync?: typeof nodeExecFileSync;
18
+ makeTempDir?: () => string;
19
+ readPathFile?: (path: string) => string;
20
+ removeTempDir?: (path: string) => void;
21
+ }
22
+
23
+ export function mergePythonEnv(
24
+ configured: PythonEnvLike,
25
+ detected: PythonEnvLike = {},
26
+ ): { memoryDecayPath: string; pythonPath: string } {
27
+ return {
28
+ memoryDecayPath: configured.memoryDecayPath || detected.memoryDecayPath || "",
29
+ pythonPath: configured.pythonPath || detected.pythonPath || "",
30
+ };
31
+ }
32
+
33
+ function resolveOpenClawStateDir(
34
+ env: NodeJS.ProcessEnv,
35
+ homedir: () => string,
36
+ ): string {
37
+ return resolve(env.OPENCLAW_HOME || homedir(), ".openclaw");
38
+ }
39
+
40
+ export function resolveMemoryDecayPath(moduleFile: string): string {
41
+ const packageDir = dirname(resolve(moduleFile));
42
+ const packageParent = dirname(packageDir);
43
+ return basename(packageParent) === "src" ? dirname(packageParent) : packageParent;
44
+ }
45
+
46
+ export function buildPythonCandidates({
47
+ pluginRoot,
48
+ env = process.env,
49
+ isWin = process.platform === "win32",
50
+ homedir = nodeHomedir,
51
+ existsSync = nodeExistsSync,
52
+ }: Omit<DetectPythonEnvOptions, "execFileSync" | "makeTempDir" | "readPathFile" | "removeTempDir">): string[] {
53
+ const siblingRoots = [
54
+ resolve(pluginRoot, "../../memory-decay"),
55
+ resolve(pluginRoot, "../../memory-decay-core"),
56
+ resolve(pluginRoot, "../memory-decay"),
57
+ resolve(pluginRoot, "../memory-decay-core"),
58
+ ];
59
+ const openclawStateDir = resolveOpenClawStateDir(env, homedir);
60
+ const recommendedVenv = join(
61
+ openclawStateDir,
62
+ "venvs",
63
+ "memory-decay",
64
+ isWin ? "Scripts/python.exe" : "bin/python",
65
+ );
66
+ const pathCandidates = [
67
+ env.MD_PYTHON_PATH,
68
+ recommendedVenv,
69
+ env.VIRTUAL_ENV
70
+ ? join(env.VIRTUAL_ENV, isWin ? "Scripts/python.exe" : "bin/python")
71
+ : undefined,
72
+ ...siblingRoots.map((root) =>
73
+ join(root, isWin ? ".venv/Scripts/python.exe" : ".venv/bin/python")),
74
+ ].filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0 && existsSync(candidate));
75
+
76
+ const commandCandidates = isWin ? ["python"] : ["python3", "python"];
77
+ return [...new Set([...pathCandidates, ...commandCandidates])];
78
+ }
79
+
80
+ function resolvePythonPath(
81
+ command: string,
82
+ {
83
+ execFileSync = nodeExecFileSync,
84
+ isWin = process.platform === "win32",
85
+ }: Pick<DetectPythonEnvOptions, "execFileSync" | "isWin"> = {},
86
+ ): string {
87
+ if (isAbsolute(command)) {
88
+ return command;
89
+ }
90
+
91
+ const resolver = isWin ? "where" : "which";
92
+ return execFileSync(resolver, [command], { encoding: "utf8" }).trim().split(/\r?\n/)[0];
93
+ }
94
+
95
+ export function detectPythonEnv({
96
+ pluginRoot,
97
+ env = process.env,
98
+ isWin = process.platform === "win32",
99
+ homedir = nodeHomedir,
100
+ existsSync = nodeExistsSync,
101
+ execFileSync = nodeExecFileSync,
102
+ makeTempDir = () => mkdtempSync(join(tmpdir(), "memory-decay-python-env-")),
103
+ readPathFile = (path) => readFileSync(path, "utf8"),
104
+ removeTempDir = (path) => rmSync(path, { recursive: true, force: true }),
105
+ }: DetectPythonEnvOptions): { memoryDecayPath: string; pythonPath: string } | null {
106
+ for (const candidate of buildPythonCandidates({ pluginRoot, env, isWin, homedir, existsSync })) {
107
+ try {
108
+ execFileSync(candidate, ["-c", "import memory_decay.server"], { stdio: "ignore" });
109
+ const tempDir = makeTempDir();
110
+ const outputPath = join(tempDir, "memory-decay-module-path.txt");
111
+
112
+ try {
113
+ execFileSync(candidate, [
114
+ "-c",
115
+ `import memory_decay, pathlib; pathlib.Path(${JSON.stringify(outputPath)}).write_text(str(pathlib.Path(memory_decay.__file__).resolve()))`,
116
+ ], { stdio: "ignore" });
117
+ } catch (error) {
118
+ removeTempDir(tempDir);
119
+ throw error;
120
+ }
121
+
122
+ const moduleFile = readPathFile(outputPath).trim();
123
+ removeTempDir(tempDir);
124
+
125
+ return {
126
+ pythonPath: resolvePythonPath(candidate, { execFileSync, isWin }),
127
+ memoryDecayPath: resolveMemoryDecayPath(moduleFile),
128
+ };
129
+ } catch {}
130
+ }
131
+
132
+ return null;
133
+ }