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 +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +17 -8
- package/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/amem.ts +428 -40
- package/src/clawmem.ts +202 -72
- package/src/openclaw/index.ts +8 -4
- package/src/openclaw/shell.ts +9 -1
- package/src/openclaw-paths.ts +244 -0
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
|
-
|
|
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
|
|
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
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
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
package/src/amem.ts
CHANGED
|
@@ -22,50 +22,243 @@ const EMPTY_NOTE: MemoryNote = {
|
|
|
22
22
|
context: ""
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 =
|
|
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
|
-
//
|
|
230
|
+
// Try extracting balanced JSON values before lighter repairs.
|
|
53
231
|
}
|
|
54
232
|
|
|
55
|
-
|
|
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 =
|
|
533
|
+
const parsed = parseMemoryNoteFromLLM(result.text);
|
|
146
534
|
|
|
147
|
-
if (!parsed
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1353
|
-
console.log(`${c.green}Removed plugin
|
|
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
|
-
|
|
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
|
-
//
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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]
|
|
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}
|
package/src/openclaw/index.ts
CHANGED
|
@@ -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:
|
|
41
|
-
* `handleBeforePromptBuild` (which IS awaited at `attempt.ts:
|
|
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:
|
|
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:
|
|
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) => {
|
package/src/openclaw/shell.ts
CHANGED
|
@@ -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
|
+
}
|