clawmem 0.10.2 → 0.10.4

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/AGENTS.md CHANGED
@@ -739,9 +739,10 @@ clawmem focus clear --session-id abc123
739
739
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs with A-MEM notes + links. Only runs if the MCP process stays alive long enough to tick (every 5min).
740
740
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+) for live issue data, creates markdown docs in `beads` collection, maps all dependency edge types into `memory_relations`, and triggers A-MEM enrichment for new docs. Watcher auto-triggers on `.beads/` directory changes; `beads_sync` MCP tool for manual sync. Requires `bd` binary on PATH or at `~/go/bin/bd`.
741
741
  - HTTP REST API: `clawmem serve [--port 7438]` — optional REST server on localhost. Search, retrieval, lifecycle, and graph traversal. `POST /retrieve` mirrors `memory_retrieve` with auto-routing (keyword/semantic/causal/timeline/hybrid). `POST /search` provides direct mode selection. Bearer token auth via `CLAWMEM_API_TOKEN` env var (disabled if unset).
742
- - OpenClaw memory plugin: `clawmem setup openclaw` — registers ClawMem as a native OpenClaw memory plugin (`kind: memory`, v0.10.0+). Lifecycle events on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval (context-surfacing) AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured BEFORE the LLM call that could trigger compaction; `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw); `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the `before_prompt_build` proximity heuristic missed a sudden token jump; `session_start` registers the session and caches first-turn bootstrap context. Shares the same vault as Claude Code hooks (dual-mode). SQLite busy_timeout=5000ms for concurrent access safety.
742
+ - OpenClaw memory plugin: `clawmem setup openclaw` — registers ClawMem as a native OpenClaw memory plugin (`kind: memory`, v0.10.0+). Lifecycle events on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval (context-surfacing) AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured BEFORE the LLM call that could trigger compaction; `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw, plus a 30s default void-hook timeout from OpenClaw v2026.4.26+ that logs slow handlers but does not cancel the underlying postrun work); `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the `before_prompt_build` proximity heuristic missed a sudden token jump; `session_start` registers the session and caches first-turn bootstrap context. Shares the same vault as Claude Code hooks (dual-mode). SQLite busy_timeout=5000ms for concurrent access safety.
743
743
  - **§14.3 pure-memory migration (v0.10.0):** v0.10.0 drops the `ClawMemContextEngine` class entirely. Previous versions registered as `kind: context-engine` and implemented `assemble()`/`bootstrap()`/`afterTurn()`/`compact()` on a class. v0.10.0 registers as `kind: memory` and wires every lifecycle surface through plugin hooks on the event bus. Retrieval pipeline, composite scoring, vault format, and the 5 registered agent tools are unchanged — this is a packaging and registration change, not a behavioral one.
744
744
  - **v2026.4.11 packaging fix (v0.10.0):** `src/openclaw/package.json` declares `openclaw.extensions: ["./index.ts"]` (required by v2026.4.11's discovery path), and `cmdSetupOpenClaw` defaults to `cpSync(..., { recursive: true, dereference: true })` because v2026.4.11's discoverer uses `readdirSync({ withFileTypes: true })` where symlink `isDirectory() === false`. A `--link` opt-in flag preserves the old symlink behavior for dev workflows with a warning.
745
+ - **Profile-aware install (v0.10.4, §28.1, issue #11):** `cmdSetupOpenClaw` is now a three-path installer. When `openclaw` is on `PATH` it delegates to `openclaw plugins install <pluginDir> --force` (or `-l` for `--link`), inheriting OpenClaw's destination resolution (`OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `--profile`), manifest validation, security scan, install records, slot selection, and registry refresh. The plugin is auto-enabled — Path 1's "Next steps" output drops `openclaw plugins enable clawmem` because it's redundant. `--link` in delegated mode records the source in `plugins.load.paths` (NOT a filesystem symlink, so the v2026.4.11 discovery skip does NOT apply). When `openclaw` is absent, ClawMem falls back to recursive copy at a destination resolved by `src/openclaw-paths.ts:resolveExtensionsDirNoOpenClaw` — a faithful mirror of OpenClaw's `resolveConfigDir` (priority: `OPENCLAW_STATE_DIR` → `OPENCLAW_CONFIG_PATH` → `OPENCLAW_HOME`/`HOME`/`USERPROFILE`/`os.homedir()`/`cwd` → `~/.openclaw`). The fallback's filesystem symlink in `--link` mode is still subject to OpenClaw's discovery skip (warning surfaced). `--remove` tries `openclaw plugins uninstall clawmem --force` first; on failure (typical for legacy unmanaged direct-copy installs), warns the user that config/install records may need manual repair, then runs manual cleanup at the resolved path. On success, also performs a constrained stale cleanup of any leftover unmanaged directory at the same path. `--help` / `-h` short-circuits before any spawn or filesystem work and prints the full flag + env-var reference (§28.2). Helpers in `src/openclaw-paths.ts` take injected `env` + `homedir` for testability.
745
746
  - **v2026.4.18 synchronous-`register()` constraint:** OpenClaw v2026.4.18 (`fix(plugins): enforce synchronous registration`) throws `"plugin register must be synchronous"` if the plugin's `register()` function returns a Promise. ClawMem's `register(api)` in `src/openclaw/index.ts` is intentionally synchronous — all `await` work lives inside per-event handlers, never in registration itself. Companion change: register failures now atomically roll back side effects (globals, hook registrations, tool registrations), so any future throw inside `register()` will leave OpenClaw in a clean state. Keep the function synchronous and throw-free; do not add `async` or top-level `await`.
746
747
  - **Precompact correctness contract (v0.10.0):** The load-bearing precompact path is `before_prompt_build`, NOT `before_compaction`. `before_prompt_build` is awaited synchronously before the LLM call that could trigger compaction, so it cannot race the compactor. `before_compaction` is fire-and-forget at OpenClaw's call site and exists only as a safety net for the rare case the proximity heuristic in `before_prompt_build` missed a sudden token-count jump. Do not describe `before_compaction` as the primary surface — the guarantee comes from `before_prompt_build`. v0.3.0 did the pre-emptive extraction from `ContextEngine.compact()` via `delegateCompactionToRuntime()`; v0.10.0 moves it into `before_prompt_build` where it can be awaited before the triggering LLM call. User-visible behavior is equivalent or better: state capture now happens strictly before compaction, not in a race with it.
747
748
  - Hermes Agent MemoryProvider plugin: `src/hermes/` — Python plugin implementing Hermes's `MemoryProvider` ABC. **Preferred install:** copy into `$HERMES_HOME/plugins/clawmem/` (typically `~/.hermes/plugins/clawmem/`) — Hermes #10529 (v2026.4.13+) added user-plugin discovery, so this path survives `git pull` of hermes-agent. **Bundled-style install:** `hermes-agent/plugins/memory/clawmem/` still works (bundled-first precedence on name collisions). Uses shell-out for lifecycle hooks (session-bootstrap, context-surfacing, extraction) and REST API for tools (retrieve, get, session_log, timeline, similar). Plugin manages its own transcript JSONL for ClawMem hooks. Supports external (you run `clawmem serve`) and managed (plugin starts/stops serve) modes. **Agent-context isolation:** `initialize()` reads the `agent_context` kwarg Hermes passes ("primary"/"subagent"/"cron"/"flush"); for non-primary contexts the read-side hooks (session-bootstrap, context-surfacing) still run but the write-side surfaces (`sync_turn` transcript appends, `on_session_end` extraction, `on_pre_compress` precompact) early-return so cron system prompts and subagent intermediate state never reach the vault.
package/CLAUDE.md CHANGED
@@ -739,9 +739,10 @@ clawmem focus clear --session-id abc123
739
739
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs with A-MEM notes + links. Only runs if the MCP process stays alive long enough to tick (every 5min).
740
740
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+) for live issue data, creates markdown docs in `beads` collection, maps all dependency edge types into `memory_relations`, and triggers A-MEM enrichment for new docs. Watcher auto-triggers on `.beads/` directory changes; `beads_sync` MCP tool for manual sync. Requires `bd` binary on PATH or at `~/go/bin/bd`.
741
741
  - HTTP REST API: `clawmem serve [--port 7438]` — optional REST server on localhost. Search, retrieval, lifecycle, and graph traversal. `POST /retrieve` mirrors `memory_retrieve` with auto-routing (keyword/semantic/causal/timeline/hybrid). `POST /search` provides direct mode selection. Bearer token auth via `CLAWMEM_API_TOKEN` env var (disabled if unset).
742
- - OpenClaw memory plugin: `clawmem setup openclaw` — registers ClawMem as a native OpenClaw memory plugin (`kind: memory`, v0.10.0+). Lifecycle events on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval (context-surfacing) AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured BEFORE the LLM call that could trigger compaction; `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw); `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the `before_prompt_build` proximity heuristic missed a sudden token jump; `session_start` registers the session and caches first-turn bootstrap context. Shares the same vault as Claude Code hooks (dual-mode). SQLite busy_timeout=5000ms for concurrent access safety.
742
+ - OpenClaw memory plugin: `clawmem setup openclaw` — registers ClawMem as a native OpenClaw memory plugin (`kind: memory`, v0.10.0+). Lifecycle events on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval (context-surfacing) AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured BEFORE the LLM call that could trigger compaction; `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw, plus a 30s default void-hook timeout from OpenClaw v2026.4.26+ that logs slow handlers but does not cancel the underlying postrun work); `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the `before_prompt_build` proximity heuristic missed a sudden token jump; `session_start` registers the session and caches first-turn bootstrap context. Shares the same vault as Claude Code hooks (dual-mode). SQLite busy_timeout=5000ms for concurrent access safety.
743
743
  - **§14.3 pure-memory migration (v0.10.0):** v0.10.0 drops the `ClawMemContextEngine` class entirely. Previous versions registered as `kind: context-engine` and implemented `assemble()`/`bootstrap()`/`afterTurn()`/`compact()` on a class. v0.10.0 registers as `kind: memory` and wires every lifecycle surface through plugin hooks on the event bus. Retrieval pipeline, composite scoring, vault format, and the 5 registered agent tools are unchanged — this is a packaging and registration change, not a behavioral one.
744
744
  - **v2026.4.11 packaging fix (v0.10.0):** `src/openclaw/package.json` declares `openclaw.extensions: ["./index.ts"]` (required by v2026.4.11's discovery path), and `cmdSetupOpenClaw` defaults to `cpSync(..., { recursive: true, dereference: true })` because v2026.4.11's discoverer uses `readdirSync({ withFileTypes: true })` where symlink `isDirectory() === false`. A `--link` opt-in flag preserves the old symlink behavior for dev workflows with a warning.
745
+ - **Profile-aware install (v0.10.4, §28.1, issue #11):** `cmdSetupOpenClaw` is now a three-path installer. When `openclaw` is on `PATH` it delegates to `openclaw plugins install <pluginDir> --force` (or `-l` for `--link`), inheriting OpenClaw's destination resolution (`OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `--profile`), manifest validation, security scan, install records, slot selection, and registry refresh. The plugin is auto-enabled — Path 1's "Next steps" output drops `openclaw plugins enable clawmem` because it's redundant. `--link` in delegated mode records the source in `plugins.load.paths` (NOT a filesystem symlink, so the v2026.4.11 discovery skip does NOT apply). When `openclaw` is absent, ClawMem falls back to recursive copy at a destination resolved by `src/openclaw-paths.ts:resolveExtensionsDirNoOpenClaw` — a faithful mirror of OpenClaw's `resolveConfigDir` (priority: `OPENCLAW_STATE_DIR` → `OPENCLAW_CONFIG_PATH` → `OPENCLAW_HOME`/`HOME`/`USERPROFILE`/`os.homedir()`/`cwd` → `~/.openclaw`). The fallback's filesystem symlink in `--link` mode is still subject to OpenClaw's discovery skip (warning surfaced). `--remove` tries `openclaw plugins uninstall clawmem --force` first; on failure (typical for legacy unmanaged direct-copy installs), warns the user that config/install records may need manual repair, then runs manual cleanup at the resolved path. On success, also performs a constrained stale cleanup of any leftover unmanaged directory at the same path. `--help` / `-h` short-circuits before any spawn or filesystem work and prints the full flag + env-var reference (§28.2). Helpers in `src/openclaw-paths.ts` take injected `env` + `homedir` for testability.
745
746
  - **v2026.4.18 synchronous-`register()` constraint:** OpenClaw v2026.4.18 (`fix(plugins): enforce synchronous registration`) throws `"plugin register must be synchronous"` if the plugin's `register()` function returns a Promise. ClawMem's `register(api)` in `src/openclaw/index.ts` is intentionally synchronous — all `await` work lives inside per-event handlers, never in registration itself. Companion change: register failures now atomically roll back side effects (globals, hook registrations, tool registrations), so any future throw inside `register()` will leave OpenClaw in a clean state. Keep the function synchronous and throw-free; do not add `async` or top-level `await`.
746
747
  - **Precompact correctness contract (v0.10.0):** The load-bearing precompact path is `before_prompt_build`, NOT `before_compaction`. `before_prompt_build` is awaited synchronously before the LLM call that could trigger compaction, so it cannot race the compactor. `before_compaction` is fire-and-forget at OpenClaw's call site and exists only as a safety net for the rare case the proximity heuristic in `before_prompt_build` missed a sudden token-count jump. Do not describe `before_compaction` as the primary surface — the guarantee comes from `before_prompt_build`. v0.3.0 did the pre-emptive extraction from `ContextEngine.compact()` via `delegateCompactionToRuntime()`; v0.10.0 moves it into `before_prompt_build` where it can be awaited before the triggering LLM call. User-visible behavior is equivalent or better: state capture now happens strictly before compaction, not in a race with it.
747
748
  - Hermes Agent MemoryProvider plugin: `src/hermes/` — Python plugin implementing Hermes's `MemoryProvider` ABC. **Preferred install:** copy into `$HERMES_HOME/plugins/clawmem/` (typically `~/.hermes/plugins/clawmem/`) — Hermes #10529 (v2026.4.13+) added user-plugin discovery, so this path survives `git pull` of hermes-agent. **Bundled-style install:** `hermes-agent/plugins/memory/clawmem/` still works (bundled-first precedence on name collisions). Uses shell-out for lifecycle hooks (session-bootstrap, context-surfacing, extraction) and REST API for tools (retrieve, get, session_log, timeline, similar). Plugin manages its own transcript JSONL for ClawMem hooks. Supports external (you run `clawmem serve`) and managed (plugin starts/stops serve) modes. **Agent-context isolation:** `initialize()` reads the `agent_context` kwarg Hermes passes ("primary"/"subagent"/"cron"/"flush"); for non-primary contexts the read-side hooks (session-bootstrap, context-surfacing) still run but the write-side surfaces (`sync_turn` transcript appends, `on_session_end` extraction, `on_pre_compress` precompact) early-return so cron system prompts and subagent intermediate state never reach the vault.
package/README.md CHANGED
@@ -188,12 +188,18 @@ clawmem setup mcp # Register MCP server in ~/.claude.json (31 tools)
188
188
  ClawMem registers as a native OpenClaw memory plugin (`kind: memory`, v0.10.0+). Same 90/10 automatic retrieval, delivered through OpenClaw's plugin-hook bus instead of Claude Code hooks.
189
189
 
190
190
  ```bash
191
- clawmem setup openclaw # Installs plugin into ~/.openclaw/extensions/clawmem (copy, not symlink)
191
+ # v0.10.4+: profile-aware. Delegates to `openclaw plugins install --force` when the OpenClaw CLI
192
+ # is on PATH (auto-enables the plugin, honors OPENCLAW_STATE_DIR, OPENCLAW_CONFIG_PATH, --profile).
193
+ # Falls back to a recursive copy honoring OPENCLAW_STATE_DIR when the CLI is absent.
194
+ clawmem setup openclaw
195
+
196
+ # Custom profile (e.g. dev profile at ~/.openclaw-dev):
197
+ OPENCLAW_STATE_DIR=~/.openclaw-dev clawmem setup openclaw
192
198
  ```
193
199
 
194
200
  **What the plugin provides:**
195
201
  - **`before_prompt_build` hook (load-bearing)** - prompt-aware retrieval (context-surfacing + session-bootstrap) AND the pre-emptive `precompact-extract` run when token usage approaches the compaction threshold. This is the authoritative path for precompact state capture because it runs synchronously before the LLM call that would trigger compaction, so it cannot race the compactor.
196
- - **`agent_end` hook** - decision extraction, handoff generation, feedback loop (parallel, fire-and-forget at the OpenClaw call site)
202
+ - **`agent_end` hook** - decision extraction, handoff generation, feedback loop (parallel, fire-and-forget at the OpenClaw call site). OpenClaw v2026.4.26+ also enforces a 30s default void-hook timeout on `agent_end` — slow handlers are logged but the underlying postrun work is not cancelled (fail-open).
197
203
  - **`before_compaction` hook (defense-in-depth fallback)** - fires `precompact-extract` again for the rare case where `before_prompt_build`'s proximity heuristic missed a sudden token-count jump. Fire-and-forget at OpenClaw's call site, so it races the compactor and offers no correctness guarantee on its own — the `before_prompt_build` path is what actually holds the invariant.
198
204
  - **`session_start` hook** - session registration + cached first-turn bootstrap context
199
205
  - **5 agent tools** - `clawmem_search`, `clawmem_get`, `clawmem_session_log`, `clawmem_timeline`, `clawmem_similar`
@@ -1099,13 +1105,16 @@ clawmem install-service --enable
1099
1105
  #### OpenClaw-specific
1100
1106
 
1101
1107
  ```bash
1102
- # Install the OpenClaw plugin (v0.10.0+: recursively copies into ~/.openclaw/extensions/clawmem)
1108
+ # Install the OpenClaw plugin
1109
+ # v0.10.4+: delegates to `openclaw plugins install --force` when the CLI is on PATH (auto-enables;
1110
+ # honors OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH / --profile). Falls back to recursive copy
1111
+ # honoring OPENCLAW_STATE_DIR when the CLI is absent.
1112
+ # v0.10.0–v0.10.3: hardcoded recursive copy into ~/.openclaw/extensions/clawmem (issue #11).
1103
1113
  clawmem setup openclaw
1104
- # Then follow the printed next steps:
1105
- # 1. openclaw plugins enable clawmem (switches memory slot + disables memory-core)
1106
- # 2. openclaw gateway restart
1107
- # 3. Configure GPU endpoints if needed (see setup openclaw output)
1108
- # Multi-user installs also need: sudo chown -R <gateway-user>:<gateway-group> ~/.openclaw/extensions/clawmem
1114
+ # Then follow the printed next steps. The exact list depends on which path ran:
1115
+ # Path 1 (delegated): plugin already auto-enabled just `openclaw gateway restart` + GPU config
1116
+ # Path 3 (CLI absent): manually `openclaw plugins enable clawmem`, then restart + GPU config
1117
+ # Multi-user installs also need: sudo chown -R <gateway-user>:<gateway-group> <extensions>/clawmem
1109
1118
  # Requires OpenClaw v2026.4.11+.
1110
1119
  ```
1111
1120
 
package/SKILL.md CHANGED
@@ -796,7 +796,7 @@ clawmem focus clear --session-id abc123
796
796
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs and runs Phase 2 merge / Phase 3 deductive synthesis. **v0.8.2:** hosted by either `clawmem watch` (long-lived, canonical) or `clawmem mcp` (per-session fallback); every tick acquires a `light-consolidation` `worker_leases` row before doing work, so dual-hosting against the same vault is safe.
797
797
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+), creates markdown docs, maps dependency edges into `memory_relations`. Watcher auto-triggers on `.beads/` changes; `beads_sync` MCP for manual sync.
798
798
  - HTTP REST API: `clawmem serve [--port 7438]` — optional REST server on localhost. Search, retrieval, lifecycle, and graph traversal. `POST /retrieve` mirrors `memory_retrieve` with auto-routing (keyword/semantic/causal/timeline/hybrid). `POST /search` provides direct mode selection. Bearer token auth via `CLAWMEM_API_TOKEN` env var (disabled if unset).
799
- - OpenClaw memory plugin (v0.10.0+): `clawmem setup openclaw` — registers as native OpenClaw memory plugin (`kind: memory`). Dual-mode: shares vault with Claude Code hooks. Hook wiring on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured before the LLM call that could trigger compaction. `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw's call site). `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the proximity heuristic in `before_prompt_build` missed a sudden token jump. `session_start` registers the session + caches first-turn bootstrap context. The §14.3 migration removed the `ClawMemContextEngine` class and moved the plugin from the `context-engine` slot to the `memory` slot. Requires OpenClaw v2026.4.11+ (earlier versions do not support the new discovery contract).
799
+ - OpenClaw memory plugin (v0.10.0+): `clawmem setup openclaw` — registers as native OpenClaw memory plugin (`kind: memory`). Dual-mode: shares vault with Claude Code hooks. Hook wiring on the plugin-hook bus: `before_prompt_build` is the **load-bearing** path — it runs prompt-aware retrieval AND the pre-emptive `precompact-extract` synchronously when token usage approaches the compaction threshold, so state is captured before the LLM call that could trigger compaction. `agent_end` runs decision-extractor + handoff-generator + feedback-loop in parallel (fire-and-forget at OpenClaw's call site, plus a 30s default void-hook timeout from OpenClaw v2026.4.26+ that logs slow handlers but does not cancel the underlying postrun work). `before_compaction` is **defense-in-depth fallback only** — fire-and-forget, races the compactor, exists for the rare case where the proximity heuristic in `before_prompt_build` missed a sudden token jump. `session_start` registers the session + caches first-turn bootstrap context. The §14.3 migration removed the `ClawMemContextEngine` class and moved the plugin from the `context-engine` slot to the `memory` slot. Requires OpenClaw v2026.4.11+ (earlier versions do not support the new discovery contract).
800
800
  - Hermes Agent MemoryProvider plugin: `src/hermes/` — Python plugin for Hermes's memory system. Shell-out hooks for lifecycle (prefetch, extraction, precompact), REST API for tools. Plugin-managed transcript JSONL bridges Hermes turn pairs to ClawMem file format. Shares vault with Claude Code and OpenClaw. **Preferred install path:** `$HERMES_HOME/plugins/clawmem/` (Hermes #10529 user-plugin discovery, v2026.4.13+) — survives `git pull` of hermes-agent. The bundled `hermes-agent/plugins/memory/clawmem/` path still works. **Agent-context isolation:** read-side hooks always run; write-side surfaces (`sync_turn`, `on_session_end`, `on_pre_compress`) early-return when `agent_context != "primary"` so cron/subagent state never reaches the vault.
801
801
 
802
802
  ## Tool Selection (one-liner)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/amem.ts CHANGED
@@ -22,50 +22,243 @@ const EMPTY_NOTE: MemoryNote = {
22
22
  context: ""
23
23
  };
24
24
 
25
- /**
26
- * Extract and parse JSON from LLM output, handling:
27
- * - Markdown code blocks (```json ... ```)
28
- * - Leading/trailing prose around JSON
29
- * - Truncated JSON from token limits (repairs arrays/objects)
30
- */
31
- export function extractJsonFromLLM(raw: string): any | null {
32
- let text = raw.trim();
25
+ function uniqueStrings(values: string[]): string[] {
26
+ const seen = new Set<string>();
27
+ const out: string[] = [];
28
+ for (const value of values) {
29
+ const trimmed = value.trim();
30
+ if (!trimmed || seen.has(trimmed)) continue;
31
+ seen.add(trimmed);
32
+ out.push(trimmed);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ type LinkRelationType = 'semantic' | 'supporting' | 'contradicts';
38
+
39
+ type ParsedLinkGeneration = {
40
+ target_idx: number;
41
+ link_type: LinkRelationType;
42
+ confidence: number;
43
+ reasoning: string;
44
+ };
45
+
46
+
47
+ function isLinkRelationType(value: unknown): value is LinkRelationType {
48
+ return value === 'semantic' || value === 'supporting' || value === 'contradicts';
49
+ }
50
+
51
+ function isUnitIntervalNumber(value: unknown): value is number {
52
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
53
+ }
54
+
55
+ function isParsedLinkGeneration(value: unknown): value is ParsedLinkGeneration {
56
+ if (!value || typeof value !== 'object') return false;
57
+ const link = value as Record<string, unknown>;
58
+ return Number.isInteger(link.target_idx) &&
59
+ (link.target_idx as number) > 0 &&
60
+ isLinkRelationType(link.link_type) &&
61
+ isUnitIntervalNumber(link.confidence) &&
62
+ typeof link.reasoning === 'string';
63
+ }
64
+
65
+ export function parseMemoryNoteFromLLM(raw: string): MemoryNote | null {
66
+ const parsed = extractJsonFromLLM(raw) as Partial<MemoryNote> | null;
67
+ if (parsed && Array.isArray(parsed.keywords)) {
68
+ return {
69
+ keywords: parsed.keywords.filter((v): v is string => typeof v === 'string'),
70
+ tags: Array.isArray(parsed.tags) ? parsed.tags.filter((v): v is string => typeof v === 'string') : [],
71
+ context: typeof parsed.context === 'string' ? parsed.context : '',
72
+ };
73
+ }
74
+
75
+ const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
76
+ const keywords = uniqueStrings(lines.filter((line) => line.startsWith('lex:')).map((line) => line.slice(4).trim()));
77
+ const context = lines.find((line) => line.startsWith('hyde:'))?.slice(5).trim() ?? '';
78
+ if (keywords.length === 0 && !context) {
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ keywords,
84
+ tags: [],
85
+ context,
86
+ };
87
+ }
88
+
89
+ export function parseLinkGenerationFromLLM(raw: string): ParsedLinkGeneration[] | null {
90
+ const parsed = extractJsonFromLLM(raw) as { result?: unknown } | unknown[] | null;
91
+ const wrapped = parsed && typeof parsed === 'object' ? parsed as { result?: unknown } : null;
92
+ const items = Array.isArray(parsed)
93
+ ? parsed
94
+ : wrapped && Array.isArray(wrapped.result)
95
+ ? wrapped.result
96
+ : null;
97
+
98
+ if (!items) return null;
99
+ const validItems = items.filter(isParsedLinkGeneration);
100
+ return validItems.length === items.length ? validItems : null;
101
+ }
102
+
103
+ function tryParseJsonWithCommaRepair(text: string): any | null {
104
+ // Repair missing commas between object fields without rewriting adjacent array strings.
105
+ const repaired = text.replace(
106
+ /(\]|\}|"(?:[^"\\]|\\.)*"|-?\d(?:[\d.eE+-])*|true|false|null)(\s*"[^"\n]+"\s*:)/g,
107
+ '$1,$2'
108
+ );
109
+ if (repaired === text) return null;
110
+ try {
111
+ return JSON.parse(repaired);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function extractBalancedJsonCandidate(text: string): string | null {
118
+ if (text[0] !== '{' && text[0] !== '[') return null;
119
+
120
+ const stack: string[] = [text[0]!];
121
+ let inString = false;
122
+ let escaped = false;
123
+
124
+ for (let i = 1; i < text.length; i++) {
125
+ const ch = text[i]!;
126
+ if (inString) {
127
+ if (escaped) {
128
+ escaped = false;
129
+ } else if (ch === '\\') {
130
+ escaped = true;
131
+ } else if (ch === '"') {
132
+ inString = false;
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (ch === '"') {
138
+ inString = true;
139
+ continue;
140
+ }
141
+
142
+ if (ch === '{' || ch === '[') {
143
+ stack.push(ch);
144
+ continue;
145
+ }
146
+
147
+ if (ch === '}' || ch === ']') {
148
+ const expected = ch === '}' ? '{' : '[';
149
+ if (stack[stack.length - 1] !== expected) return null;
150
+ stack.pop();
151
+ if (stack.length === 0) return text.slice(0, i + 1);
152
+ }
153
+ }
154
+
155
+ return null;
156
+ }
33
157
 
34
- // Strip markdown code blocks
35
- const codeBlock = text.match(/```(?:json)?\s*\n?([\s\S]*?)(?:\n```|$)/);
36
- if (codeBlock) {
37
- text = codeBlock[1]!.trim();
158
+ function parseBalancedJsonValue(candidate: string): any | null {
159
+ try {
160
+ return JSON.parse(candidate);
161
+ } catch {
162
+ return tryParseJsonWithCommaRepair(candidate);
163
+ }
164
+ }
165
+
166
+ function jsonStartsAtTrimmedLineStart(text: string, index: number): boolean {
167
+ const lineStart = text.lastIndexOf('\n', index - 1) + 1;
168
+ return text.slice(lineStart, index).trim().length === 0;
169
+ }
170
+
171
+ function findLineStartJsonAfter(text: string, index: number): number {
172
+ for (let i = index; i < text.length; i++) {
173
+ if ((text[i] === '{' || text[i] === '[') && jsonStartsAtTrimmedLineStart(text, i)) return i;
174
+ }
175
+ return -1;
176
+ }
177
+
178
+ function isLikelyInlineProseLiteral(text: string, index: number): boolean {
179
+ return !jsonStartsAtTrimmedLineStart(text, index) && !hasPayloadCueBefore(text, index);
180
+ }
181
+
182
+ function collectParseableBalancedJsonCandidates(
183
+ text: string,
184
+ startIndex: number
185
+ ): Array<{ start: number; parsed: any }> {
186
+ const candidates: Array<{ start: number; parsed: any }> = [];
187
+ for (let i = startIndex; i < text.length; i++) {
188
+ if (text[i] !== '{' && text[i] !== '[') continue;
189
+
190
+ const balancedCandidate = extractBalancedJsonCandidate(text.slice(i));
191
+ if (!balancedCandidate) continue;
192
+
193
+ const parsed = parseBalancedJsonValue(balancedCandidate);
194
+ if (parsed !== null) candidates.push({ start: i, parsed });
195
+
196
+ i += balancedCandidate.length - 1;
197
+ }
198
+ return candidates;
199
+ }
200
+
201
+ function selectBalancedJsonCandidate(text: string, candidates: Array<{ start: number; parsed: any }>): any | null {
202
+ if (candidates.length === 0) return null;
203
+
204
+ const payloadCandidate = candidates.find((candidate) => hasPayloadCueBefore(text, candidate.start));
205
+ if (payloadCandidate) return payloadCandidate.parsed;
206
+
207
+ const first = candidates[0]!;
208
+ if (candidates.length > 1 && (hasExampleCueBefore(text, first.start) || isLikelyInlineProseLiteral(text, first.start))) {
209
+ const laterPayload = candidates.find((candidate) =>
210
+ !hasExampleCueBefore(text, candidate.start) && !isLikelyInlineProseLiteral(text, candidate.start)
211
+ );
212
+ if (laterPayload) return laterPayload.parsed;
38
213
  }
39
214
 
40
- // Find the first [ or { to skip leading prose
41
- const arrStart = text.indexOf('[');
42
- const objStart = text.indexOf('{');
215
+ return first.parsed;
216
+ }
217
+
218
+ function parseJsonCandidate(raw: string): any | null {
219
+ const trimmed = raw.trim();
220
+ const arrStart = trimmed.indexOf('[');
221
+ const objStart = trimmed.indexOf('{');
43
222
  if (arrStart === -1 && objStart === -1) return null;
44
223
 
45
224
  const start = arrStart === -1 ? objStart : objStart === -1 ? arrStart : Math.min(arrStart, objStart);
46
- text = text.slice(start);
225
+ const text = trimmed.slice(start);
47
226
 
48
- // Try parsing as-is first
49
227
  try {
50
228
  return JSON.parse(text);
51
229
  } catch {
52
- // Attempt truncated JSON repair
230
+ // Try extracting balanced JSON values before lighter repairs.
53
231
  }
54
232
 
55
- // Repair truncated arrays: find last complete object, close the array
233
+ const firstBalancedCandidate = extractBalancedJsonCandidate(text);
234
+ if (firstBalancedCandidate) {
235
+ if (hasExampleCueBefore(trimmed, start) || isLikelyInlineProseLiteral(trimmed, start)) {
236
+ const laterLineStartJson = findLineStartJsonAfter(trimmed, start + firstBalancedCandidate.length);
237
+ if (laterLineStartJson !== -1) {
238
+ const laterParsed = parseJsonCandidate(trimmed.slice(laterLineStartJson));
239
+ if (laterParsed !== null) return laterParsed;
240
+ }
241
+ }
242
+ const balancedParsed = selectBalancedJsonCandidate(
243
+ trimmed,
244
+ collectParseableBalancedJsonCandidates(trimmed, start)
245
+ );
246
+ if (balancedParsed !== null) return balancedParsed;
247
+ }
248
+
249
+ const commaRepaired = tryParseJsonWithCommaRepair(text);
250
+ if (commaRepaired !== null) return commaRepaired;
251
+
56
252
  if (text.startsWith('[')) {
57
253
  const lastBrace = text.lastIndexOf('}');
58
254
  if (lastBrace > 0) {
59
255
  const repaired = text.slice(0, lastBrace + 1) + ']';
60
256
  try { return JSON.parse(repaired); } catch { /* continue */ }
61
257
  }
62
- // Might be an empty or trivial array
63
258
  try { return JSON.parse(text.replace(/,\s*$/, '') + ']'); } catch { /* continue */ }
64
259
  }
65
260
 
66
- // Repair truncated objects: find last complete value, close the object
67
261
  if (text.startsWith('{')) {
68
- // Try closing at each } from the end
69
262
  for (let i = text.length - 1; i > 0; i--) {
70
263
  if (text[i] === '}' || text[i] === '"' || text[i] === '0' || text[i] === '1' ||
71
264
  text[i] === '2' || text[i] === '3' || text[i] === '4' || text[i] === '5' ||
@@ -80,6 +273,201 @@ export function extractJsonFromLLM(raw: string): any | null {
80
273
  return null;
81
274
  }
82
275
 
276
+ function collectFenceBlocks(text: string): Array<{ start: number; end: number; tag: string | null; body: string }> {
277
+ const lines = text.split('\n');
278
+ const fences: Array<{ start: number; end: number; tag: string | null; body: string }> = [];
279
+ let offset = 0;
280
+ let open: { start: number; tag: string | null; bodyLines: string[] } | null = null;
281
+
282
+ for (const line of lines) {
283
+ const trimmed = line.trim();
284
+ const lineStart = offset;
285
+ const lineEnd = offset + line.length;
286
+
287
+ if (!open) {
288
+ const match = trimmed.match(/^```([^\s`]*)?\s*$/);
289
+ if (match) {
290
+ open = { start: lineStart, tag: match[1] || null, bodyLines: [] };
291
+ }
292
+ } else if (trimmed === '```') {
293
+ fences.push({
294
+ start: open.start,
295
+ end: Math.min(text.length, lineEnd + 1),
296
+ tag: open.tag,
297
+ body: open.bodyLines.join('\n').trim(),
298
+ });
299
+ open = null;
300
+ } else {
301
+ open.bodyLines.push(line);
302
+ }
303
+
304
+ offset = lineEnd + 1;
305
+ }
306
+
307
+ if (open) {
308
+ fences.push({
309
+ start: open.start,
310
+ end: text.length,
311
+ tag: open.tag,
312
+ body: open.bodyLines.join('\n').trim(),
313
+ });
314
+ }
315
+
316
+ return fences;
317
+ }
318
+
319
+ function stripAnyFences(text: string): string {
320
+ const ranges = collectAnyFenceRanges(text);
321
+ if (ranges.length === 0) return text.trim();
322
+
323
+ let out = '';
324
+ let cursor = 0;
325
+ for (const range of ranges) {
326
+ out += text.slice(cursor, range.start);
327
+ cursor = range.end;
328
+ }
329
+ out += text.slice(cursor);
330
+ return out.trim();
331
+ }
332
+
333
+ function collectStructuralFences(text: string): Array<{ body: string; isJson: boolean; start: number; end: number }> {
334
+ return collectFenceBlocks(text)
335
+ .filter((fence) => fence.tag === null || fence.tag === 'json')
336
+ .map((fence) => ({
337
+ body: fence.body,
338
+ isJson: fence.tag === 'json',
339
+ start: fence.start,
340
+ end: fence.end,
341
+ }));
342
+ }
343
+
344
+ function collectAnyFenceRanges(text: string): Array<{ start: number; end: number }> {
345
+ return collectFenceBlocks(text).map((fence) => ({ start: fence.start, end: fence.end }));
346
+ }
347
+
348
+ function findFirstJsonStartOutsideFences(
349
+ text: string,
350
+ fences: Array<{ start: number; end: number }>
351
+ ): number {
352
+ let fenceIndex = 0;
353
+ for (let i = 0; i < text.length; i++) {
354
+ while (fenceIndex < fences.length && i >= fences[fenceIndex]!.end) {
355
+ fenceIndex++;
356
+ }
357
+ if (fenceIndex < fences.length) {
358
+ const fence = fences[fenceIndex]!;
359
+ if (i >= fence.start && i < fence.end) {
360
+ i = fence.end - 1;
361
+ continue;
362
+ }
363
+ }
364
+ if (text[i] === '[' || text[i] === '{') return i;
365
+ }
366
+ return -1;
367
+ }
368
+
369
+ function hasExampleCueBefore(text: string, index: number): boolean {
370
+ let end = index;
371
+ while (end > 0 && /\s/.test(text[end - 1]!)) end--;
372
+ const lineStart = text.lastIndexOf('\n', end - 1) + 1;
373
+ const cue = text.slice(lineStart, end).toLowerCase();
374
+ return cue.includes('example') || cue.includes('e.g.') || cue.includes('schema');
375
+ }
376
+
377
+ function hasPayloadCueBefore(text: string, index: number): boolean {
378
+ let end = index;
379
+ while (end > 0 && /\s/.test(text[end - 1]!)) end--;
380
+ const lineStart = text.lastIndexOf('\n', end - 1) + 1;
381
+ const cue = text.slice(lineStart, end).trim().toLowerCase();
382
+ return /^(actual|result|final|answer)(?:\s+(json|answer|response|payload))?[:\-]?$/.test(cue);
383
+ }
384
+ /**
385
+ * Extract and parse JSON from LLM output, handling:
386
+ * - Markdown code blocks (```json ... ```)
387
+ * - Leading/trailing prose around JSON
388
+ * - Truncated JSON from token limits (repairs arrays/objects)
389
+ */
390
+ export function extractJsonFromLLM(raw: string): any | null {
391
+ const text = raw.trim();
392
+ if (!text) return null;
393
+
394
+ const fences = collectStructuralFences(text);
395
+ const jsonFences = fences.filter((fence) => fence.isJson);
396
+ const anyFenceRanges = collectAnyFenceRanges(text);
397
+ const outsideJsonStart = findFirstJsonStartOutsideFences(text, anyFenceRanges);
398
+ const outsideLooksLikeExample = outsideJsonStart !== -1 && hasExampleCueBefore(text, outsideJsonStart);
399
+ const outsideLooksLikePayload = outsideJsonStart !== -1 && hasPayloadCueBefore(text, outsideJsonStart);
400
+ const preferredJsonFences = jsonFences.filter((fence) =>
401
+ !hasExampleCueBefore(text, fence.start) &&
402
+ !(text.startsWith('```') && fence.start === fences[0]?.start && outsideLooksLikePayload)
403
+ );
404
+ const preferredUntaggedFences = fences.filter((fence) => !fence.isJson && !hasExampleCueBefore(text, fence.start));
405
+ const firstPreferredJsonFence = preferredJsonFences[0] ?? null;
406
+ const firstPreferredUntaggedFence = preferredUntaggedFences[0] ?? null;
407
+ const untaggedFenceLooksLikeExample = firstPreferredUntaggedFence ? hasExampleCueBefore(text, firstPreferredUntaggedFence.start) : false;
408
+ const outsidePrecedesPreferredJsonFence = !!firstPreferredJsonFence && outsideJsonStart < firstPreferredJsonFence.start;
409
+ const tryOutsideBeforeJsonFences = outsideJsonStart !== -1 &&
410
+ !outsideLooksLikeExample &&
411
+ (!outsidePrecedesPreferredJsonFence || outsideLooksLikePayload);
412
+
413
+ if (text.startsWith('```') && fences[0]?.start === 0 && !outsideLooksLikePayload && (fences[0]!.isJson || preferredJsonFences.length === 0)) {
414
+ const parsedLeadingFence = parseJsonCandidate(fences[0]!.body);
415
+ if (parsedLeadingFence !== null) return parsedLeadingFence;
416
+ }
417
+
418
+ const tryOutsideFences = () => {
419
+ const withoutFences = stripAnyFences(text);
420
+ if (!withoutFences || withoutFences === text) return null;
421
+ return parseJsonCandidate(withoutFences);
422
+ };
423
+
424
+ if (!text.startsWith('```') && !firstPreferredJsonFence && firstPreferredUntaggedFence && outsideLooksLikeExample && !untaggedFenceLooksLikeExample) {
425
+ const parsedUntaggedFence = parseJsonCandidate(firstPreferredUntaggedFence.body);
426
+ if (parsedUntaggedFence !== null) return parsedUntaggedFence;
427
+ }
428
+
429
+ if (tryOutsideBeforeJsonFences) {
430
+ const parsedOutsideFences = tryOutsideFences();
431
+ if (parsedOutsideFences !== null) return parsedOutsideFences;
432
+ }
433
+
434
+ for (const fence of preferredJsonFences) {
435
+ const parsedJsonFence = parseJsonCandidate(fence.body);
436
+ if (parsedJsonFence !== null) return parsedJsonFence;
437
+ }
438
+
439
+ if (!tryOutsideBeforeJsonFences) {
440
+ const parsedOutsideFences = tryOutsideFences();
441
+ if (parsedOutsideFences !== null) return parsedOutsideFences;
442
+ }
443
+
444
+ if (fences.length === 0) {
445
+ const parsedRaw = parseJsonCandidate(text);
446
+ if (parsedRaw !== null) return parsedRaw;
447
+ }
448
+
449
+ const fallbackFences = preferredJsonFences.length === 0
450
+ ? [
451
+ ...preferredUntaggedFences,
452
+ ...jsonFences.filter((fence) => hasExampleCueBefore(text, fence.start)),
453
+ ...(text.startsWith('```')
454
+ ? fences.slice(1).filter((fence) => !fence.isJson && hasExampleCueBefore(text, fence.start))
455
+ : fences.filter((fence) => !fence.isJson && hasExampleCueBefore(text, fence.start))),
456
+ ]
457
+ : [
458
+ ...jsonFences.filter((fence) => hasExampleCueBefore(text, fence.start)),
459
+ ...(text.startsWith('```')
460
+ ? fences.slice(1).filter((fence) => !fence.isJson)
461
+ : fences.filter((fence) => !fence.isJson)),
462
+ ];
463
+ for (const fence of fallbackFences) {
464
+ const parsedFence = parseJsonCandidate(fence.body);
465
+ if (parsedFence !== null) return parsedFence;
466
+ }
467
+
468
+ return null;
469
+ }
470
+
83
471
  /**
84
472
  * Construct a memory note for a document using LLM analysis.
85
473
  * Extracts keywords, tags, and context summary.
@@ -142,9 +530,11 @@ Return ONLY valid JSON in this exact format:
142
530
  return EMPTY_NOTE;
143
531
  }
144
532
 
145
- const parsed = extractJsonFromLLM(result.text) as MemoryNote | null;
533
+ const parsed = parseMemoryNoteFromLLM(result.text);
146
534
 
147
- if (!parsed || !Array.isArray(parsed.keywords) || !Array.isArray(parsed.tags) || typeof parsed.context !== 'string') {
535
+ if (!parsed) {
536
+ console.log(`[amem] RAW memory note output for docId ${docId}:`);
537
+ console.log(result.text);
148
538
  console.log(`[amem] Invalid/unparseable JSON for docId ${docId}`);
149
539
  return EMPTY_NOTE;
150
540
  }
@@ -302,34 +692,32 @@ Include all ${neighbors.length} neighbors in your response.`;
302
692
  return 0;
303
693
  }
304
694
 
305
- const parsed = extractJsonFromLLM(result.text) as Array<{
306
- target_idx: number;
307
- link_type: 'semantic' | 'supporting' | 'contradicts';
308
- confidence: number;
309
- reasoning: string;
310
- }> | null;
695
+ const parsed = parseLinkGenerationFromLLM(result.text);
311
696
 
312
- if (!Array.isArray(parsed)) {
697
+ if (!parsed) {
698
+ console.log(`[amem] RAW link generation output for docId ${docId}:`);
699
+ console.log(result.text);
313
700
  console.log(`[amem] Invalid/unparseable JSON for link generation docId ${docId}`);
314
701
  return 0;
315
702
  }
316
703
 
704
+
317
705
  // Insert links into memory_relations
318
706
  let linksCreated = 0;
319
707
  const now = new Date().toISOString();
708
+ const linkedTargetIndexes = new Set<number>();
320
709
 
321
710
  for (const link of parsed) {
322
- // Validate link structure
323
- if (typeof link.target_idx !== 'number' ||
324
- link.target_idx < 1 ||
325
- link.target_idx > neighbors.length ||
326
- !['semantic', 'supporting', 'contradicts'].includes(link.link_type) ||
327
- typeof link.confidence !== 'number') {
711
+ const neighbor = neighbors[link.target_idx - 1];
712
+ if (!neighbor) {
713
+ console.log(`[amem] Skipping out-of-range link target ${link.target_idx} for docId ${docId}`);
328
714
  continue;
329
715
  }
330
-
331
- const neighbor = neighbors[link.target_idx - 1];
332
- if (!neighbor) continue;
716
+ if (linkedTargetIndexes.has(link.target_idx)) {
717
+ console.log(`[amem] Skipping duplicate link target ${link.target_idx} for docId ${docId}`);
718
+ continue;
719
+ }
720
+ linkedTargetIndexes.add(link.target_idx);
333
721
 
334
722
  // Insert link with INSERT OR IGNORE for idempotency
335
723
  store.db.prepare(`
package/src/clawmem.ts CHANGED
@@ -70,6 +70,10 @@ import {
70
70
  clearSessionFocus,
71
71
  focusFilePath,
72
72
  } from "./session-focus.ts";
73
+ import {
74
+ resolveExtensionsDirNoOpenClaw,
75
+ printSetupOpenClawHelp,
76
+ } from "./openclaw-paths.ts";
73
77
 
74
78
  enableProductionMode();
75
79
 
@@ -1324,13 +1328,26 @@ function readOpenClawConfigValue(key: string): string | undefined {
1324
1328
  }
1325
1329
 
1326
1330
  async function cmdSetupOpenClaw(args: string[]) {
1331
+ // §28.2 — short-circuit on --help / -h before any spawn or filesystem work.
1332
+ if (args.includes("--help") || args.includes("-h")) {
1333
+ printSetupOpenClawHelp();
1334
+ return;
1335
+ }
1336
+
1327
1337
  const remove = args.includes("--remove");
1328
1338
  const linkMode = args.includes("--link");
1329
1339
  const pluginDir = pathResolve(import.meta.dir, "openclaw");
1330
- const extensionsDir = pathResolve(process.env.HOME || "~", ".openclaw", "extensions");
1340
+
1341
+ // Resolve the extensions/clawmem path we would touch directly. Both Path 1
1342
+ // link-mode pre-cleanup and Path 3 direct-copy install need this. Path 1
1343
+ // copy-mode delegation does NOT use linkPath because OpenClaw's
1344
+ // `--force` install owns the destination resolution there.
1345
+ const extensionsDir = resolveExtensionsDirNoOpenClaw();
1331
1346
  const linkPath = pathResolve(extensionsDir, "clawmem");
1332
1347
 
1333
- // Check if openclaw CLI is available
1348
+ // Probe whether the openclaw CLI is on PATH. Used to choose between
1349
+ // delegation (Path 1) and direct-copy fallback (Path 3) for installs,
1350
+ // and between CLI uninstall and manual cleanup for --remove.
1334
1351
  const hasOpenClawCli = (() => {
1335
1352
  try {
1336
1353
  const r = Bun.spawnSync(["openclaw", "--version"], { stdout: "pipe", stderr: "pipe" });
@@ -1339,27 +1356,65 @@ async function cmdSetupOpenClaw(args: string[]) {
1339
1356
  })();
1340
1357
 
1341
1358
  if (remove) {
1342
- // Actually uninstall mirror of install behavior
1359
+ // §28.1 H3 / R1 / R4: try-and-fall-back uninstall + constrained stale
1360
+ // cleanup. CLI uninstall is preferred (handles managed config + slot
1361
+ // resets); manual cleanup is the legacy fallback for unmanaged
1362
+ // direct-cpSync installs from older ClawMem versions. On CLI failure we
1363
+ // fall through AND emit a warning so the user knows config/install
1364
+ // records may need manual repair.
1365
+ let cliUninstallSucceeded = false;
1366
+ let cliUninstallFailed = false;
1367
+ if (hasOpenClawCli) {
1368
+ const r = Bun.spawnSync(
1369
+ ["openclaw", "plugins", "uninstall", "clawmem", "--force"],
1370
+ { stdout: "inherit", stderr: "inherit" },
1371
+ );
1372
+ if (r.exitCode === 0) {
1373
+ cliUninstallSucceeded = true;
1374
+ } else {
1375
+ cliUninstallFailed = true;
1376
+ console.log(
1377
+ `${c.yellow}Warning: openclaw plugins uninstall clawmem failed (exit ${r.exitCode}).${c.reset}`,
1378
+ );
1379
+ console.log(
1380
+ `${c.yellow} OpenClaw config and install records may still reference clawmem.${c.reset}`,
1381
+ );
1382
+ console.log(
1383
+ `${c.yellow} Falling back to manual cleanup of the install directory.${c.reset}`,
1384
+ );
1385
+ }
1386
+ }
1387
+
1388
+ // Constrained stale cleanup (R3 in BACKLOG §28.1): even if CLI uninstall
1389
+ // succeeded, an old unmanaged direct-copy directory at the same path
1390
+ // could still be present (managed-link + unmanaged-copy side-by-side).
1391
+ // Always check the exact extensions/clawmem path and remove if present.
1343
1392
  let removed = false;
1344
1393
  try {
1345
- const stat = await import("fs").then(m => m.lstatSync(linkPath));
1346
- if (stat.isSymbolicLink() || stat.isDirectory()) {
1347
- const { unlinkSync, rmSync } = await import("fs");
1348
- if (stat.isSymbolicLink()) {
1349
- unlinkSync(linkPath);
1350
- } else {
1351
- rmSync(linkPath, { recursive: true });
1352
- }
1353
- console.log(`${c.green}Removed plugin from ${linkPath}${c.reset}`);
1394
+ const { lstatSync, unlinkSync, rmSync } = await import("fs");
1395
+ const stat = lstatSync(linkPath);
1396
+ if (stat.isSymbolicLink()) {
1397
+ unlinkSync(linkPath);
1398
+ console.log(`${c.green}Removed plugin symlink at ${linkPath}${c.reset}`);
1399
+ removed = true;
1400
+ } else if (stat.isDirectory()) {
1401
+ rmSync(linkPath, { recursive: true });
1402
+ console.log(`${c.green}Removed plugin directory at ${linkPath}${c.reset}`);
1354
1403
  removed = true;
1355
1404
  }
1356
1405
  } catch (e: any) {
1357
1406
  if (e.code !== "ENOENT") throw e;
1358
- console.log(`${c.dim}Plugin not installed at ${linkPath}${c.reset}`);
1407
+ if (!cliUninstallSucceeded && !cliUninstallFailed) {
1408
+ // Truly nothing to do — no CLI, no directory.
1409
+ console.log(`${c.dim}Plugin not installed at ${linkPath}${c.reset}`);
1410
+ }
1359
1411
  }
1360
1412
 
1413
+ // Slot reset: only meaningful if the CLI is reachable. CLI uninstall
1414
+ // already clears the memory slot if it succeeded, but if uninstall
1415
+ // failed we still attempt slot reset because config slots can be
1416
+ // populated separately from install records.
1361
1417
  if (hasOpenClawCli) {
1362
- // Reset the memory slot if ClawMem owned it (post-§14.3-migration installs).
1363
1418
  const memSlot = readOpenClawConfigValue("plugins.slots.memory");
1364
1419
  if (memSlot === "clawmem") {
1365
1420
  Bun.spawnSync(["openclaw", "config", "unset", "plugins.slots.memory"], { stdout: "inherit", stderr: "inherit" });
@@ -1378,7 +1433,8 @@ async function cmdSetupOpenClaw(args: string[]) {
1378
1433
  return;
1379
1434
  }
1380
1435
 
1381
- // Verify plugin source files exist
1436
+ // Verify plugin source files exist (cheap defense-in-depth — surfaces
1437
+ // ClawMem packaging bugs immediately, before any spawn).
1382
1438
  if (!existsSync(pathResolve(pluginDir, "index.ts"))) {
1383
1439
  die(`OpenClaw plugin files not found at ${pluginDir}`);
1384
1440
  }
@@ -1389,44 +1445,103 @@ async function cmdSetupOpenClaw(args: string[]) {
1389
1445
  die(`Plugin package.json not found at ${pluginDir}/package.json — required for OpenClaw v2026.4.11+ discovery`);
1390
1446
  }
1391
1447
 
1392
- // Create extensions directory
1393
- if (!existsSync(extensionsDir)) {
1394
- mkdirSync(extensionsDir, { recursive: true });
1395
- }
1396
-
1397
- // Remove any stale install (symlink or directory) before re-installing.
1398
- // OpenClaw v2026.4.11+ discovery (discoverInDirectory in ids-*.js) uses
1399
- // readdirSync({ withFileTypes: true }) where symlinks report
1400
- // isDirectory() === false and get silently skipped, so copy mode is the
1401
- // default. The --link flag keeps symlink behavior for older OpenClaw
1402
- // versions or local development workflows where editing the live source
1403
- // should take effect without re-running setup.
1404
- try {
1405
- const { lstatSync, unlinkSync, rmSync } = await import("fs");
1406
- const stat = lstatSync(linkPath);
1407
- if (stat.isSymbolicLink()) {
1408
- unlinkSync(linkPath);
1409
- console.log(`${c.dim}Replaced stale symlink at ${linkPath}${c.reset}`);
1410
- } else if (stat.isDirectory()) {
1411
- rmSync(linkPath, { recursive: true });
1412
- console.log(`${c.dim}Replaced existing directory at ${linkPath}${c.reset}`);
1448
+ // §28.1 H1/H2: choose path. Path 1 = openclaw plugins install delegation;
1449
+ // Path 3 = direct-copy fallback honoring OPENCLAW_STATE_DIR.
1450
+ let delegated = false;
1451
+ if (hasOpenClawCli) {
1452
+ // Path 1: delegate to OpenClaw. Auto-enables, writes install records,
1453
+ // applies slot selection, refreshes registry.
1454
+ if (linkMode) {
1455
+ // §28.1 H2 link-mode: OpenClaw rejects --force with --link, so we do
1456
+ // manual stale cleanup before delegating to preserve idempotence.
1457
+ try {
1458
+ const { lstatSync, unlinkSync, rmSync } = await import("fs");
1459
+ const stat = lstatSync(linkPath);
1460
+ if (stat.isSymbolicLink()) {
1461
+ unlinkSync(linkPath);
1462
+ console.log(`${c.dim}Replaced stale symlink at ${linkPath}${c.reset}`);
1463
+ } else if (stat.isDirectory()) {
1464
+ rmSync(linkPath, { recursive: true });
1465
+ console.log(`${c.dim}Replaced existing directory at ${linkPath}${c.reset}`);
1466
+ }
1467
+ } catch (e: any) {
1468
+ if (e.code !== "ENOENT") throw e;
1469
+ }
1470
+ const r = Bun.spawnSync(
1471
+ ["openclaw", "plugins", "install", pluginDir, "-l"],
1472
+ { stdout: "inherit", stderr: "inherit" },
1473
+ );
1474
+ if (r.exitCode !== 0) {
1475
+ die(`openclaw plugins install -l failed (exit ${r.exitCode}); aborting setup`);
1476
+ }
1477
+ // OpenClaw's `plugins install -l` records the source path in
1478
+ // plugins.load.paths and persists a path install record (not a
1479
+ // filesystem symlink). The v2026.4.11 symlink-discovery skip does
1480
+ // NOT apply to this mode — discovery uses the load-path entry.
1481
+ console.log(`${c.green}Linked local plugin path via openclaw plugins install -l (profile-aware, auto-enabled)${c.reset}`);
1482
+ console.log(`${c.dim} Source recorded in plugins.load.paths — edits to ${pluginDir} take effect on next gateway restart.${c.reset}`);
1413
1483
  } else {
1414
- die(`${linkPath} exists but is not a symlink or directory. Remove it manually and re-run setup.`);
1484
+ // §28.1 H2 copy-mode: --force makes OpenClaw replace existing target,
1485
+ // preserving idempotence across reruns.
1486
+ const r = Bun.spawnSync(
1487
+ ["openclaw", "plugins", "install", pluginDir, "--force"],
1488
+ { stdout: "inherit", stderr: "inherit" },
1489
+ );
1490
+ if (r.exitCode !== 0) {
1491
+ die(`openclaw plugins install --force failed (exit ${r.exitCode}); aborting setup`);
1492
+ }
1493
+ console.log(`${c.green}Installed plugin via openclaw plugins install --force (profile-aware, auto-enabled)${c.reset}`);
1415
1494
  }
1416
- } catch (e: any) {
1417
- if (e.code !== "ENOENT") throw e;
1418
- }
1419
-
1420
- if (linkMode) {
1421
- const { symlinkSync } = await import("fs");
1422
- symlinkSync(pluginDir, linkPath);
1423
- console.log(`${c.green}Installed plugin: ${linkPath} → ${pluginDir} (symlink)${c.reset}`);
1424
- console.log(`${c.yellow} Warning: symlink mode. OpenClaw v2026.4.11+ discovery skips${c.reset}`);
1425
- console.log(`${c.yellow} symlinks silently. Re-run without --link on current releases.${c.reset}`);
1495
+ delegated = true;
1426
1496
  } else {
1427
- const { cpSync } = await import("fs");
1428
- cpSync(pluginDir, linkPath, { recursive: true, dereference: true });
1429
- console.log(`${c.green}Installed plugin: ${linkPath} (copied from ${pluginDir})${c.reset}`);
1497
+ // Path 3: direct-copy fallback. Honors OPENCLAW_STATE_DIR via the
1498
+ // resolveExtensionsDirNoOpenClaw helper. Profile awareness is limited
1499
+ // to env vars (no manifest validation, no security scan, no install
1500
+ // records) — surface that to the user.
1501
+ console.log(`${c.yellow}openclaw CLI not on PATH — using direct-copy install.${c.reset}`);
1502
+ console.log(`${c.yellow} Profile awareness limited to OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH${c.reset}`);
1503
+ console.log(`${c.yellow} env vars. Install OpenClaw to enable manifest validation, security${c.reset}`);
1504
+ console.log(`${c.yellow} scans, and full plugin lifecycle management.${c.reset}`);
1505
+
1506
+ // Create extensions directory.
1507
+ if (!existsSync(extensionsDir)) {
1508
+ mkdirSync(extensionsDir, { recursive: true });
1509
+ }
1510
+
1511
+ // Remove any stale install (symlink or directory) before re-installing.
1512
+ // OpenClaw v2026.4.11+ discovery (discoverInDirectory in ids-*.js) uses
1513
+ // readdirSync({ withFileTypes: true }) where symlinks report
1514
+ // isDirectory() === false and get silently skipped, so copy mode is the
1515
+ // default. The --link flag keeps symlink behavior for older OpenClaw
1516
+ // versions or local development workflows where editing the live source
1517
+ // should take effect without re-running setup.
1518
+ try {
1519
+ const { lstatSync, unlinkSync, rmSync } = await import("fs");
1520
+ const stat = lstatSync(linkPath);
1521
+ if (stat.isSymbolicLink()) {
1522
+ unlinkSync(linkPath);
1523
+ console.log(`${c.dim}Replaced stale symlink at ${linkPath}${c.reset}`);
1524
+ } else if (stat.isDirectory()) {
1525
+ rmSync(linkPath, { recursive: true });
1526
+ console.log(`${c.dim}Replaced existing directory at ${linkPath}${c.reset}`);
1527
+ } else {
1528
+ die(`${linkPath} exists but is not a symlink or directory. Remove it manually and re-run setup.`);
1529
+ }
1530
+ } catch (e: any) {
1531
+ if (e.code !== "ENOENT") throw e;
1532
+ }
1533
+
1534
+ if (linkMode) {
1535
+ const { symlinkSync } = await import("fs");
1536
+ symlinkSync(pluginDir, linkPath);
1537
+ console.log(`${c.green}Installed plugin: ${linkPath} → ${pluginDir} (symlink)${c.reset}`);
1538
+ console.log(`${c.yellow} Warning: symlink mode. OpenClaw v2026.4.11+ discovery skips${c.reset}`);
1539
+ console.log(`${c.yellow} symlinks silently. Re-run without --link on current releases.${c.reset}`);
1540
+ } else {
1541
+ const { cpSync } = await import("fs");
1542
+ cpSync(pluginDir, linkPath, { recursive: true, dereference: true });
1543
+ console.log(`${c.green}Installed plugin: ${linkPath} (copied from ${pluginDir})${c.reset}`);
1544
+ }
1430
1545
  }
1431
1546
 
1432
1547
  // ----- §14.3 upgrade migration -----
@@ -1472,30 +1587,45 @@ async function cmdSetupOpenClaw(args: string[]) {
1472
1587
  console.log(`have a bug where plugins.slots.contextEngine is silently dropped`);
1473
1588
  console.log(`during config normalization (openclaw/openclaw#64192).`);
1474
1589
 
1475
- // Remaining steps. CLI discovery finds the plugin immediately because the
1476
- // plugin dir now ships a package.json with openclaw.extensions declared, so
1477
- // `openclaw plugins enable clawmem` can run before any gateway restart.
1478
- // The enable command switches the exclusive memory slot to clawmem and
1479
- // disables memory-core/memory-lancedb automatically. Then the gateway
1480
- // restart applies the new slot assignment.
1590
+ // §28.1 H1: dual next-steps output. Path 1 (delegated) auto-enables via
1591
+ // persistPluginInstall, so the legacy "Step 1: enable" instruction is
1592
+ // redundant and would mislead users. Path 3 (direct copy) writes only
1593
+ // the plugin files; the user must still run `openclaw plugins enable`
1594
+ // themselves, so the original 4-step output is preserved verbatim.
1481
1595
  console.log();
1482
1596
  console.log(`${c.bold}Next steps:${c.reset}`);
1483
1597
  console.log();
1484
- console.log(` 1. Enable ClawMem as the active memory plugin:`);
1485
- console.log(` ${c.cyan}openclaw plugins enable clawmem${c.reset}`);
1486
- console.log(` ${c.dim}(Switches plugins.slots.memory to clawmem and disables memory-core if active.)${c.reset}`);
1487
- console.log();
1488
- console.log(` 2. Restart the gateway to apply:`);
1489
- console.log(` ${c.cyan}openclaw gateway restart${c.reset}`);
1490
- console.log();
1491
- console.log(` 3. Configure GPU endpoints (if not using defaults):`);
1492
- console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuEmbed http://YOUR_GPU:8088${c.reset}`);
1493
- console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlm http://YOUR_GPU:8089${c.reset}`);
1494
- console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlmModel qwen3${c.reset}`);
1495
- console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuRerank http://YOUR_GPU:8090${c.reset}`);
1496
- console.log();
1497
- console.log(` 4. Start the REST API (for agent tools):`);
1498
- console.log(` ${c.cyan}clawmem serve &${c.reset}`);
1598
+ if (delegated) {
1599
+ // Path 1 — plugin already enabled and registered by openclaw plugins install.
1600
+ console.log(` 1. Restart the gateway to apply:`);
1601
+ console.log(` ${c.cyan}openclaw gateway restart${c.reset}`);
1602
+ console.log();
1603
+ console.log(` 2. Configure GPU endpoints (if not using defaults):`);
1604
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuEmbed http://YOUR_GPU:8088${c.reset}`);
1605
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlm http://YOUR_GPU:8089${c.reset}`);
1606
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlmModel qwen3${c.reset}`);
1607
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuRerank http://YOUR_GPU:8090${c.reset}`);
1608
+ console.log();
1609
+ console.log(` 3. Start the REST API (for agent tools):`);
1610
+ console.log(` ${c.cyan}clawmem serve &${c.reset}`);
1611
+ } else {
1612
+ // Path 3 — direct-copy install. User still needs to enable + restart.
1613
+ console.log(` 1. Enable ClawMem as the active memory plugin:`);
1614
+ console.log(` ${c.cyan}openclaw plugins enable clawmem${c.reset}`);
1615
+ console.log(` ${c.dim}(Switches plugins.slots.memory to clawmem and disables memory-core if active.)${c.reset}`);
1616
+ console.log();
1617
+ console.log(` 2. Restart the gateway to apply:`);
1618
+ console.log(` ${c.cyan}openclaw gateway restart${c.reset}`);
1619
+ console.log();
1620
+ console.log(` 3. Configure GPU endpoints (if not using defaults):`);
1621
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuEmbed http://YOUR_GPU:8088${c.reset}`);
1622
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlm http://YOUR_GPU:8089${c.reset}`);
1623
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlmModel qwen3${c.reset}`);
1624
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuRerank http://YOUR_GPU:8090${c.reset}`);
1625
+ console.log();
1626
+ console.log(` 4. Start the REST API (for agent tools):`);
1627
+ console.log(` ${c.cyan}clawmem serve &${c.reset}`);
1628
+ }
1499
1629
  console.log();
1500
1630
  console.log(`${c.bold}Important: keep dreaming disabled${c.reset}`);
1501
1631
  console.log(` ClawMem runs its own consolidation workers (CLAWMEM_ENABLE_CONSOLIDATION`);
@@ -2844,7 +2974,7 @@ ${c.bold}Setup:${c.reset}
2844
2974
  clawmem collection remove <name>
2845
2975
  clawmem setup hooks [--remove] Install/remove Claude Code hooks
2846
2976
  clawmem setup mcp [--remove] Register/remove MCP in ~/.claude.json
2847
- clawmem setup openclaw [--remove] Show OpenClaw plugin installation steps
2977
+ clawmem setup openclaw [--link] [--remove] Install/remove ClawMem as OpenClaw memory plugin
2848
2978
  clawmem install-service [--enable] Install systemd watcher service
2849
2979
 
2850
2980
  ${c.bold}Indexing:${c.reset}
@@ -37,8 +37,8 @@
37
37
  * 4. REST API service (`clawmem serve`) lifecycle — unchanged.
38
38
  *
39
39
  * §14.3 critical correctness contract: `agent_end` is fire-and-forget at
40
- * `attempt.ts:2470-2496`. Precompact-extract MUST run inside
41
- * `handleBeforePromptBuild` (which IS awaited at `attempt.ts:1873`), gated
40
+ * `attempt.ts:3379-3402`. Precompact-extract MUST run inside
41
+ * `handleBeforePromptBuild` (which IS awaited at `attempt.ts:2610`), gated
42
42
  * by the proximity heuristic in `compaction-threshold.ts`. See `engine.ts`
43
43
  * top-of-file comment for the full rationale.
44
44
  */
@@ -161,7 +161,7 @@ const clawmemPlugin = {
161
161
  // ----- Plugin Hook: before_prompt_build (AWAITED — load-bearing path) -----
162
162
  // Both context-surfacing retrieval injection and pre-emptive precompact
163
163
  // extraction live here. handleBeforePromptBuild is async and the OpenClaw
164
- // attempt path awaits the result at attempt.ts:1873 before building the
164
+ // attempt path awaits the result at attempt.ts:2610 before building the
165
165
  // effective prompt. precompact-extract therefore runs strictly before
166
166
  // the LLM call that could trigger compaction on this turn.
167
167
  api.on(
@@ -175,7 +175,11 @@ const clawmemPlugin = {
175
175
  // ----- Plugin Hook: agent_end (FIRE-AND-FORGET in core) -----
176
176
  // Decision-extractor, handoff-generator, and feedback-loop run here.
177
177
  // These writes are eventually-consistent (saveMemory dedupes), so the
178
- // fire-and-forget context at attempt.ts:2470-2496 is acceptable.
178
+ // fire-and-forget context at attempt.ts:3379-3402 is acceptable.
179
+ // OpenClaw v2026.4.26+ also enforces a 30s default void-hook timeout
180
+ // (DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK in src/plugins/hooks.ts) — a
181
+ // timed-out handler is logged but our underlying postrun work is not
182
+ // cancelled, so eventual consistency is preserved.
179
183
  // precompact-extract is intentionally NOT in this handler — it lives
180
184
  // in handleBeforePromptBuild for correctness reasons.
181
185
  api.on("agent_end", async (event: AgentEndEvent, ctx: AgentEndContext) => {
@@ -7,7 +7,15 @@
7
7
 
8
8
  import { execFile, spawn, type ChildProcess } from "node:child_process";
9
9
  import { existsSync } from "node:fs";
10
- import { resolve } from "node:path";
10
+ import { dirname, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ // ESM equivalent of CommonJS __dirname. This package declares
14
+ // "type": "module", so __dirname is not defined when loaded by a plain
15
+ // Node.js ESM loader (e.g. OpenClaw's plugin host). Bun shims __dirname
16
+ // in ESM, which is why this regression is invisible under `bun test`.
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
11
19
 
12
20
  // =============================================================================
13
21
  // Types
@@ -0,0 +1,244 @@
1
+ /**
2
+ * OpenClaw path-resolution helpers + setup help text for `clawmem setup openclaw`.
3
+ *
4
+ * §28.1 (issue #11): historically `cmdSetupOpenClaw` hardcoded
5
+ * `~/.openclaw/extensions/clawmem` and ignored `OPENCLAW_STATE_DIR`. v0.10.4
6
+ * delegates to `openclaw plugins install` when the CLI is on PATH (which
7
+ * inherits OpenClaw's own `resolveConfigDir(env)` semantics) and falls back
8
+ * to a direct-copy install otherwise. The fallback path needs to mirror
9
+ * OpenClaw's `resolveConfigDir` precisely for env-var-honoring custom
10
+ * profile installs to land in the expected directory.
11
+ *
12
+ * Mirrors: openclaw/src/utils.ts:119 (resolveConfigDir) and
13
+ * openclaw/src/infra/home-dir.ts (home resolution).
14
+ *
15
+ * Helpers take injected `env` + `homedir` so they are testable in isolation
16
+ * without needing to touch process.env or the real os.homedir.
17
+ */
18
+
19
+ import { homedir as defaultHomedir } from "node:os";
20
+ import { dirname, resolve as pathResolve } from "node:path";
21
+
22
+ // =============================================================================
23
+ // Types
24
+ // =============================================================================
25
+
26
+ export type EnvLike = Record<string, string | undefined>;
27
+ export type HomedirFn = () => string;
28
+
29
+ export interface PathResolverOpts {
30
+ env?: EnvLike;
31
+ homedir?: HomedirFn;
32
+ }
33
+
34
+ // =============================================================================
35
+ // Trim / normalize
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Plain trim — used for OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH so the
40
+ * fallback resolver mirrors OpenClaw's `resolveConfigDir` (utils.ts:119)
41
+ * EXACTLY. OpenClaw applies only `.trim()` to those env vars, so a value
42
+ * of `"undefined"` or `"null"` is treated as a literal directory name (the
43
+ * user shot themselves in the foot, but ClawMem must agree with OpenClaw
44
+ * about WHERE the foot is shot — diverging here would mean Path 1 and
45
+ * Path 3 install into different locations for the same env, which is
46
+ * exactly the bug class §28.1 set out to fix).
47
+ */
48
+ export function plainTrim(value: string | undefined): string | undefined {
49
+ if (!value) return undefined;
50
+ const t = value.trim();
51
+ return t || undefined;
52
+ }
53
+
54
+ /**
55
+ * Mirrors OpenClaw's `home-dir.ts:normalize`. Treats empty strings,
56
+ * whitespace-only strings, and the literal strings "undefined" / "null" as
57
+ * unset. Used ONLY for home-resolution env vars (OPENCLAW_HOME, HOME,
58
+ * USERPROFILE) to match OpenClaw's home-dir helper; do NOT use this for
59
+ * OPENCLAW_STATE_DIR or OPENCLAW_CONFIG_PATH (see plainTrim).
60
+ */
61
+ export function trim(value: string | undefined): string | undefined {
62
+ if (!value) return undefined;
63
+ const t = value.trim();
64
+ if (!t || t === "undefined" || t === "null") return undefined;
65
+ return t;
66
+ }
67
+
68
+ // =============================================================================
69
+ // Home resolution
70
+ // =============================================================================
71
+
72
+ /**
73
+ * Mirrors `openclaw/src/infra/home-dir.ts` resolution priority:
74
+ * OPENCLAW_HOME → HOME → USERPROFILE → os.homedir() → path.resolve(cwd())
75
+ *
76
+ * `OPENCLAW_HOME` itself can begin with a tilde, in which case we expand it
77
+ * against the *next* fallback (HOME / USERPROFILE / os.homedir).
78
+ */
79
+ export function resolveHomeForOpenClaw(opts: PathResolverOpts = {}): string {
80
+ const env = opts.env ?? process.env;
81
+ const homedir = opts.homedir ?? defaultHomedir;
82
+
83
+ const explicitHome = trim(env.OPENCLAW_HOME);
84
+ if (explicitHome) {
85
+ if (
86
+ explicitHome === "~" ||
87
+ explicitHome.startsWith("~/") ||
88
+ explicitHome.startsWith("~\\")
89
+ ) {
90
+ const fallback = resolveOsHome(env, homedir);
91
+ if (fallback) {
92
+ return pathResolve(explicitHome.replace(/^~(?=$|[\\/])/, fallback));
93
+ }
94
+ // No fallback available; fall through to other priorities below.
95
+ } else {
96
+ return pathResolve(explicitHome);
97
+ }
98
+ }
99
+
100
+ const osHome = resolveOsHome(env, homedir);
101
+ if (osHome) return pathResolve(osHome);
102
+
103
+ // Last-resort: cwd. Matches `resolveRequiredHomeDir` in OpenClaw.
104
+ return pathResolve(process.cwd());
105
+ }
106
+
107
+ function resolveOsHome(env: EnvLike, homedir: HomedirFn): string | undefined {
108
+ const homeEnv = trim(env.HOME);
109
+ if (homeEnv) return homeEnv;
110
+ const userProfile = trim(env.USERPROFILE);
111
+ if (userProfile) return userProfile;
112
+ try {
113
+ const safe = trim(homedir());
114
+ if (safe) return safe;
115
+ } catch {
116
+ // os.homedir() can throw on misconfigured systems
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ // =============================================================================
122
+ // Tilde expansion
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Expands a leading `~`, `~/`, or `~\` against the OpenClaw home resolver.
127
+ * Does NOT support `~user/...` (other-user expansion) — neither does
128
+ * OpenClaw's `expandHomePrefix`.
129
+ */
130
+ export function expandHome(input: string, opts: PathResolverOpts = {}): string {
131
+ if (!input.startsWith("~")) return input;
132
+ if (
133
+ input !== "~" &&
134
+ !input.startsWith("~/") &&
135
+ !input.startsWith("~\\")
136
+ ) {
137
+ // Looks like `~user` or some other non-home tilde form; leave untouched.
138
+ return input;
139
+ }
140
+ const home = resolveHomeForOpenClaw(opts);
141
+ return input.replace(/^~(?=$|[\\/])/, home);
142
+ }
143
+
144
+ // =============================================================================
145
+ // Extensions directory resolver (CLI-absent fallback)
146
+ // =============================================================================
147
+
148
+ /**
149
+ * Resolves the `extensions/` directory we should write into when `openclaw`
150
+ * CLI is not on PATH. Mirrors `openclaw/src/utils.ts:119 resolveConfigDir`:
151
+ * 1. OPENCLAW_STATE_DIR overrides everything
152
+ * 2. OPENCLAW_CONFIG_PATH → config root = dirname(file)
153
+ * 3. <home>/.openclaw
154
+ *
155
+ * The result is `pathResolve`d so callers can compare against equally
156
+ * normalized paths.
157
+ */
158
+ export function resolveExtensionsDirNoOpenClaw(
159
+ opts: PathResolverOpts = {},
160
+ ): string {
161
+ const env = opts.env ?? process.env;
162
+
163
+ // OPENCLAW_STATE_DIR + OPENCLAW_CONFIG_PATH use plainTrim (no
164
+ // "undefined"/"null" filtering) to match OpenClaw's resolveConfigDir
165
+ // exactly. See plainTrim docstring.
166
+ const stateDir = plainTrim(env.OPENCLAW_STATE_DIR);
167
+ if (stateDir) {
168
+ return pathResolve(expandHome(stateDir, opts), "extensions");
169
+ }
170
+
171
+ const configPath = plainTrim(env.OPENCLAW_CONFIG_PATH);
172
+ if (configPath) {
173
+ return pathResolve(dirname(expandHome(configPath, opts)), "extensions");
174
+ }
175
+
176
+ return pathResolve(resolveHomeForOpenClaw(opts), ".openclaw", "extensions");
177
+ }
178
+
179
+ // =============================================================================
180
+ // `clawmem setup openclaw --help` text
181
+ // =============================================================================
182
+
183
+ /**
184
+ * Prints help for `clawmem setup openclaw`. Documents flags, env vars
185
+ * consulted, and the CLI-delegation behavior introduced in v0.10.4 (§28.1).
186
+ */
187
+ export function printSetupOpenClawHelp(): void {
188
+ const lines = [
189
+ "",
190
+ "clawmem setup openclaw [--link] [--remove] [--help|-h]",
191
+ "",
192
+ " Install ClawMem as an OpenClaw memory plugin.",
193
+ "",
194
+ " When the openclaw CLI is on PATH, this command delegates to",
195
+ " `openclaw plugins install <pluginDir>`, which auto-enables the plugin,",
196
+ " writes install records, and applies slot selection. Otherwise it falls",
197
+ " back to a direct-copy install honoring OPENCLAW_STATE_DIR.",
198
+ "",
199
+ "Flags:",
200
+ " --link Install in load-path mode instead of copying files.",
201
+ " When openclaw is on PATH, delegates to `openclaw plugins",
202
+ " install -l <path>` which records the source in",
203
+ " plugins.load.paths (NOT a filesystem symlink). When",
204
+ " openclaw is absent, falls back to a real symlink at",
205
+ " <extensions>/clawmem (note: OpenClaw v2026.4.11+",
206
+ " discovery silently skips symlinked plugins in the",
207
+ " fallback path, so prefer the delegated path).",
208
+ " --remove Uninstall ClawMem from the OpenClaw extensions dir.",
209
+ " Tries `openclaw plugins uninstall clawmem --force` first;",
210
+ " falls back to manual cleanup at the resolved extensions",
211
+ " path for legacy unmanaged installs.",
212
+ " --help, -h Print this message and exit.",
213
+ "",
214
+ "Environment variables (consulted by the CLI-absent fallback path and",
215
+ "inherited by the openclaw subprocess in the delegation path):",
216
+ " OPENCLAW_STATE_DIR Override the OpenClaw config root. Plugin",
217
+ " installs into <OPENCLAW_STATE_DIR>/extensions/",
218
+ " clawmem.",
219
+ " OPENCLAW_CONFIG_PATH Override the OpenClaw config file path; root",
220
+ " becomes dirname(OPENCLAW_CONFIG_PATH).",
221
+ " OPENCLAW_HOME Override the home directory used to resolve",
222
+ " the default ~/.openclaw root.",
223
+ " HOME / USERPROFILE Standard home-dir env vars; consulted in that",
224
+ " order when OPENCLAW_HOME is unset.",
225
+ "",
226
+ "Examples:",
227
+ " clawmem setup openclaw",
228
+ " Install with default profile.",
229
+ "",
230
+ " OPENCLAW_STATE_DIR=~/.openclaw-dev clawmem setup openclaw",
231
+ " Install into the `dev` profile (~/.openclaw-dev/extensions/clawmem).",
232
+ "",
233
+ " clawmem setup openclaw --link",
234
+ " Load-path mode (delegated install) or symlink (fallback) — local",
235
+ " development workflow where edits to the source dir take effect.",
236
+ "",
237
+ " clawmem setup openclaw --remove",
238
+ " Uninstall ClawMem and reset OpenClaw memory slot.",
239
+ "",
240
+ ];
241
+ for (const line of lines) {
242
+ console.log(line);
243
+ }
244
+ }