45 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 3d74a0b4e2 fix: v5.0.1 — critical bug fixes from code review
- C1: Remove regex g flag in cmdSearch (skipped matches due to lastIndex)
- C2: Escape user input in RegExp constructor (crash on special chars)
- C3: Convert STATE_TEMPLATE to stateTemplate() function (stale timestamp)
- C4: Validate ignorePatterns length to prevent ReDoS
- W3: Path traversal guard on agentId in resolveAgentWorkspace + discoverAgents
- W7: Correct ISO 8601 week number calculation at year boundaries
- W8: Fix false tag dedup with section-specific regex extraction
- Add context-engine-evaluation.md (decision: hooks-only, no CE variant)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:51:52 +10:00
taodengandClaude Opus 4.6 69df869335 release: v5.0.0 — conservative subagent support (parent seed + child recovery)
- Add workspace resolution helpers (isSubagentSession, parseParentAgentId, resolveAgentWorkspace)
- Parent→Child seeding: inject parent CURRENT_STATE.md into subagent context at startup
- Child→Parent recovery: new subagent_ended hook reads child unsurfaced results, merges into parent
- Add subagentSeed and subagentRecovery config keys (both default: true)
- Add /mc subagents command for cross-workspace state overview
- Bump lifecycle plugin to v5.0.0, mc-plugin to v2.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:35:29 +10:00
taodengandClaude Opus 4.6 50ff9befa7 release: v4.0.0 — ecosystem integration (marketplace, interop, skill API)
- Add marketplace metadata to both plugin manifests (author, license, category, tags)
- Declare lossless-claw complementary positioning via interop field
- Expose mc:recall as programmatic service (api.exposeService)
- Fix CJK hashtag regex to support Unicode property escapes
- Create MARKETPLACE.md for marketplace listing
- Update README with marketplace install, interop docs, and API docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:20:53 +10:00
taodengandClaude Opus 4.6 d020d46b41 release: v3.3.0 — smart recall layer
Phase 3 features:
- Relevance injection: auto-inject related history at session start
- Memory decay: old archives move to cold/ storage after 30 days
- Enhanced export: --from/--to/--tag filters, includes sessions+summaries
- /mc recall command: keyword-scored history search

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:15:46 +10:00
taodengandClaude Sonnet 4.6 f14a1a0249 feat: enhanced export with filters + /mc recall command (v3.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:14:51 +10:00
taodengandClaude Sonnet 4.6 85bb1be946 feat: memory decay — move old archives to cold storage (v3.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:12:20 +10:00
taodengandClaude Sonnet 4.6 a7aeb673b7 feat: relevance injection — inject related history at session start (v3.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:10:40 +10:00
taodengandClaude Opus 4.6 29d2d18ed5 release: v3.2.0 — mid-term memory layer
Phase 2 features:
- Daily summaries auto-generated from session logs
- Weekly summaries rolled up from dailies (every Monday)
- Auto-tagging: #tag extraction from sessions → memory/tags.md
- Enhanced search across state, archives, sessions, and summaries
- /mc summary command for browsing summaries
- /mc tags command for viewing tag index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:01:23 +10:00
taodengandClaude Sonnet 4.6 54c7ba65e7 feat: add /mc tags and /mc summary commands (v3.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:00:31 +10:00
taodengandClaude Sonnet 4.6 9c21baf461 feat: enhanced search across sessions and summaries (v3.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:59:05 +10:00
taodengandClaude Sonnet 4.6 cad5e54aca feat: auto-extract #tags from sessions to memory/tags.md (v3.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:57:49 +10:00
taodengandClaude Sonnet 4.6 e859f3bb48 feat: auto-generate weekly summaries from daily rollups (v3.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:56:48 +10:00
taodengandClaude Sonnet 4.6 92f7739db9 feat: auto-generate daily summaries from session logs (v3.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:55:25 +10:00
taodengandClaude Opus 4.6 c5401d5c93 release: v3.1.0 — short-term memory layer
Phase 1 features:
- CJK token-aware estimation (1.5 tokens/CJK char)
- Session logging to memory/sessions/ daily files
- Smart tail protection during compaction
- ignorePatterns to filter cron/subagent noise
- /mc sessions command for browsing daily logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:36:05 +10:00
taodengandClaude Sonnet 4.6 06c546084c feat: add /mc sessions command for daily session logs (v3.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:34:55 +10:00
taodengandClaude Sonnet 4.6 e9046829b5 feat: smart tail protection during compaction (v3.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:33:13 +10:00
taodengandClaude Sonnet 4.6 a0cd296ec7 fix: session log token estimation handles array content, fix TOCTOU race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:31:18 +10:00
taodengandClaude Sonnet 4.6 9d4968c44b feat: add session logging to memory/sessions/ daily files (v3.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:29:02 +10:00
taodengandClaude Sonnet 4.6 e4ac7acaed fix: ignorePatterns tests message text not object, add warn for bad regex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:27:22 +10:00
taodeng 8db3154cff feat: add ignorePatterns to filter cron/subagent noise (v3.1) 2026-03-31 14:25:19 +10:00
taodengandClaude Sonnet 4.6 cc0aad38cb refactor: hoist CJK regex and poison patterns, fix surrogate slicing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:23:58 +10:00
taodengandClaude Sonnet 4.6 a5feb5e0e5 feat: add CJK token-aware estimation helper (v3.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:21:00 +10:00
taodengandClaude Opus 4.6 e885eb393f fix: bump openclaw.plugin.json version to 3.0.0
Version was still at 2.7.0 while git history already had v3.0.0 changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:05:41 +10:00
taodengandClaude Opus 4.6 03b1da86fd Remove misplaced OCP upgrade guide from mc README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:07:19 +10:00
taodengandClaude Opus 4.6 8ea0feeb55 Add upgrade guide for v2.x skill-based users
Explains how to remove the old skills/ocp/SKILL.md to avoid
conflicts with the new plugin-based command handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:05:01 +10:00
taodengandClaude Opus 4.6 e12f3a7a73 v3.0.0: Add /mc interactive commands for Telegram/Discord/CLI
New companion plugin (mc-plugin/) registers /mc as a native OpenClaw
slash command with 10 subcommands: state, history, restore, clear,
search, settings, compact, export. All commands work across multiple
agent workspaces and return instant responses (direct file reads,
no model invocation).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:26:53 +10:00
Oracle Public Cloud User e9faf21d0c docs: add GitHub stars badge and support section 2026-03-22 20:51:45 +00:00
taodengandClaude Opus 4.6 fb0fe6b40b feat: add archive count limits and unify versions (v2.7.0)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:36:55 +10:00
taodengandClaude Sonnet 4.6 2184442bc2 fix: correct openclaw.json key paths in verify.sh --all-agents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:52:39 +10:00
taodengandClaude Sonnet 4.6 719723225d feat: add interactive setup wizard with model selection and agent config (v2.6.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:34:39 +10:00
taodengandClaude Sonnet 4.6 9f43452f03 fix: resolve workspace path from openclaw.json for cross-machine compatibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:28:53 +10:00
taodeng fe13ea19be fix: resolve workspace path from openclaw.json for cross-machine compatibility (v2.5.0) 2026-03-19 09:28:20 +10:00
taodeng 772c964740 fix: increase agent selection timeout from 10s to 20s, bump v2.4.2 2026-03-19 09:25:18 +10:00
taodeng b12bd15c8b fix: bash case-upper compat (tr instead of ^^) 2026-03-19 09:24:19 +10:00
taodengandClaude Sonnet 4.6 c366d3ec47 fix: correct agents.list path in openclaw.json detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:23:42 +10:00
taodengandClaude Opus 4.6 449f1f4916 feat: detect only alive agents during install, auto-default to All with 10s timeout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:13:40 +10:00
taodengandClaude Sonnet 4.6 6a60c1806d feat: agent detection + selective install (v2.4.0)
- post-install.sh: reads openclaw.json `list` array via python3,
  presents a numbered menu (all/specific/quit), installs SKILL.md
  to <workspace>/skills/memory-continuity/SKILL.md for chosen agents;
  falls back to manual path prompt if config absent
- post-install.sh: remove gateway restart step
- verify.sh: add --all-agents flag that runs the 3-layer check
  (SKILL.md, continuity_doctor.py, CURRENT_STATE.md) across every
  detected agent workspace and reports pass/fail per agent
- openclaw.plugin.json: bump version to 2.4.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 07:26:00 +10:00
taodeng 9e25ec5e95 fix: python 3.6 compat + clarify CURRENT_STATE.md override rule 2026-03-19 07:14:41 +10:00
taodengandClaude Sonnet 4.6 312d892853 fix: Python 3.6 compat + allow CURRENT_STATE.md override
- Replace 'Path | None' union syntax with 'Optional[Path]' (typing import)
  so continuity_doctor.py works on Python 3.6+
- Remove message-count guard in agent_end hook that blocked state writes
  when realUserMsgs.length < 2; agents can now update CURRENT_STATE.md
  regardless of conversation length

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 07:13:26 +10:00
taodengandClaude Sonnet 4.6 33c3558625 feat: add scripts/verify.sh with 3-layer validation
Layer 1: checks SKILL.md, continuity_doctor.py, openclaw.plugin.json exist
Layer 2: runs continuity_doctor.py to confirm tool is available
Layer 3: detects empty/placeholder CURRENT_STATE.md and suggests --sample

--sample flag shows a realistic production migration task (db v2→v3,
blocked on migration window sign-off) so new users understand what
high-importance state looks like before their first session.

Colour output with  ⚠️ . README Quick Start updated with verify commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 06:53:02 +10:00
taodengandClaude Opus 4.6 201c52bf75 docs: translate recovery section to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:22:50 +10:00
taodengandClaude Opus 4.6 d61c242e9f docs: add upgrade recovery guide and troubleshooting table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:05:10 +10:00
taodengandClaude Opus 4.6 9b1b99ea21 release: v2.3.0 — first fully stable release
Bump version to 2.3.0 across package.json, openclaw.plugin.json.
Add comprehensive CHANGELOG documenting all bugs found and fixed
during the v2.x development cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:33:22 +10:00
taodengandClaude Opus 4.6 24221ea749 fix: prevent trivial conversations from overwriting meaningful state
Short conversations (< 2 real user messages) no longer overwrite
existing meaningful state. This prevents the "recovery death spiral"
where:
1. Secret told → state saved 
2. /new → state injected → model ignores it → "I don't remember"
3. agent_end overwrites good state with "I don't remember" 
4. All subsequent recoveries fail permanently

Now step 3 is blocked: a 1-message "what was my secret?" conversation
won't overwrite the previously saved secret.

Also strips channel metadata and /new-style short commands from
the real message count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:25:04 +10:00
taodengandClaude Opus 4.6 2fc42661cf fix: always update state on agent_end, archive previous state
The agent_end hook was skipping state extraction when CURRENT_STATE.md
already contained meaningful content. This meant once a state was written,
subsequent conversations never updated it — the next /new would always
recover stale data.

Now: every session end overwrites CURRENT_STATE.md with the latest
conversation state. Previous state is archived to STATE_ARCHIVE_<ts>.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:10:29 +10:00
19 changed files with 5910 additions and 94 deletions
+87 -52
View File
@@ -1,75 +1,110 @@
# Changelog # Changelog
## v3.0.0 — 2026-03-24
### Summary
Major release: **interactive `/mc` commands** for Telegram, Discord, and CLI. You can now inspect, search, restore, and manage agent memory directly from chat.
### Added
- **`/mc` slash command plugin** — 10 subcommands registered as a native OpenClaw gateway command
- `/mc state [agent]` — view current working state
- `/mc state --all` — overview of all agents' memory
- `/mc history [agent]` — list archived sessions
- `/mc restore <N> [agent]` — restore from archive
- `/mc clear [agent]` — clear state (auto-archives first)
- `/mc search <keyword>` — full-text search across all memory and archives
- `/mc settings` — view/update plugin configuration
- `/mc compact [agent]` — compress oversized state files
- `/mc export [agent|all]` — export memory to markdown file
- `/mc --help` — command reference
- **Multi-agent support** — all commands work across main + sub-agent workspaces
- **Monospace formatting** — output wrapped in code blocks for aligned display in Telegram/Discord
- **Auto-archive on clear** — clearing state automatically archives first, preventing data loss
- **Restore with backup** — restoring an archive backs up current state to `.bak`
### Changed
- Bumped version to 3.0.0
- Plugin now ships as two components: `memory-continuity` (lifecycle hooks) + `mc` (slash commands)
---
## v2.7.0 — 2026-03-22
### Changed
- Memory directory cleanup: auto-remove legacy `STATE_ARCHIVE_*.md` files
- Max memory files limit (500) with auto-cleanup of oldest files
- Archive count limit configurable via `maxArchiveCount`
---
## v2.3.0 — 2026-03-16
### Summary
First **fully stable** release. All previous versions had critical bugs causing silent failure on gateway deployments.
### Fixed
- **Workspace resolution** (`_ctx.workspaceDir`) — Hooks now read workspace path from the hook context parameter instead of `api.runtime.workspaceDir` (which was always `undefined` in gateway mode). This was the root cause of the plugin silently doing nothing on all gateway/Telegram/Discord deployments.
- **Channel metadata stripping** — User messages from Telegram/Discord are cleaned of `Conversation info (untrusted metadata)` prefixes before saving, preventing garbage in the state file.
- **Recovery death spiral** — Short conversations (< 2 real user messages) no longer overwrite existing meaningful state. Previously: tell secret → `/new` → model ignores injected context → "I don't remember" → `agent_end` overwrites secret with failure → permanent data loss.
- **State staleness** — Removed "skip if existing state is meaningful" guard that prevented state from ever updating after the first write.
### Debugging timeline
1. Hooks not firing → `api.runtime.workspaceDir` was `undefined` → fix: use `_ctx.workspaceDir`
2. State file full of Telegram metadata → fix: strip `Conversation info` / `Sender` prefixes
3. State never updating → "Existing state is meaningful, skipping" → fix: always update
4. Good state overwritten by bad → fix: require ≥ 2 real user messages to overwrite
### Tested on
- macOS: GPT-5.4 (openai-codex) ✅
- Oracle Cloud Linux: MiniMax M2.5 ✅
- Multiple OpenClaw agents via Telegram ✅
### Known limitations
- State extraction uses last user/assistant message only (not full summary)
- Models that ignore `prependSystemContext` won't surface recovered state (state file is preserved, not overwritten). Adding recovery instructions to BOOTSTRAP.md helps.
---
## v2.2.0 — 2026-03-16
### Added
- `package.json` with npm-style metadata and `openclaw.type: "plugin"` provenance
- `plugins.allow` auto-configuration in `post-install.sh`
- GitHub release with comparison table vs vector-DB plugins
### Fixed
- "plugins.allow is empty" gateway warning
- "loaded without install/load-path provenance" gateway warning
---
## v2.1.0 — 2026-03-16 ## v2.1.0 — 2026-03-16
### Summary
Clean install experience: eliminates gateway provenance warnings for fresh installations.
### Added ### Added
- `package.json` — proper npm-style metadata with `openclaw.type: "plugin"` provenance field - `openclaw.plugin.json` manifest with `configSchema`
- `plugins.allow` auto-configuration in `post-install.sh` — registers plugin in OpenClaw trust list - `scripts/post-install.sh` one-command installer
### Changed ### Changed
- `post-install.sh` now copies `package.json` to extensions directory - README updated with "Why this plugin?" section
- `openclaw.plugin.json` now includes `source` field pointing to GitHub repo
- README updated with `plugins.allow` documentation and install step
### Fixed
- "plugins.allow is empty; discovered non-bundled plugins may auto-load" warning
- "loaded without install/load-path provenance; treat as untracked local code" warning
--- ---
## v2.0.0 — 2026-03-15 ## v2.0.0 — 2026-03-15
### Summary ### Summary
Major architecture upgrade: from prompt-based skill to **lifecycle plugin**. Architecture upgrade: skill (prompt-based) → **plugin** (lifecycle hooks).
State injection and extraction now happen automatically via OpenClaw hooks,
making recovery model-agnostic and reliable across all providers.
### Added ### Added
- `index.js` — plugin entry point with 5 lifecycle hooks - `index.js` with 5 lifecycle hooks: `before_agent_start`, `before_compaction`, `before_reset`, `agent_end`, `session_end`
- `openclaw.plugin.json` — plugin manifest with configSchema - Automated state injection via `prependSystemContext`
- Automated installer (`scripts/post-install.sh`) that handles copy + config + restart - Auto-extraction of working state from conversation
### Changed
- Architecture: skill (prompt-based) → plugin (hook-based)
- `before_agent_start` hook injects `CURRENT_STATE.md` into system context automatically
- `agent_end` hook auto-extracts working state from conversation
- `before_compaction` hook preserves state through context compression
- `before_reset` hook archives state before `/new`
- `session_end` hook ensures state file exists
- README fully rewritten for plugin architecture
- post-install.sh rewritten for plugin installation flow
### Removed ### Removed
- Dependency on model cooperation for state read/write - Dependency on model cooperation for state read/write
- Reliance on `skillsSnapshot` cache clearing
### Validated
- Tested with MiniMax M2.5 and GPT-5.4 on OpenClaw 2026.3.12
- `/new` recovery: agent correctly surfaces recovered state
- Multi-turn state: conversation context (secrets, scheduled events) persists across resets
--- ---
## v0.3.0-probe — 2026-03-13 ## v1.0.0 — 2026-03-14
### Summary ### Summary
Transition from skill-only to dual-form package (skill + lifecycle plugin probe). Initial release as a skill (SKILL.md only). Required model to voluntarily read/write `CURRENT_STATE.md`. Did not work reliably with weaker models.
### Added
- `plugin/lifecycle-prototype.ts`
- `references/phase2-hook-validation.md`
### Changed
- Repository direction clarified: primary path is lifecycle plugin
- ContextEngine documented as future option, not v1 default
### Validated
- Startup continuity injection on resident subagents
- Hook wiring confirmed without `read` tool dependency
### Known limitation
- Skill-based approach unreliable: models can ignore recovery instructions
- This limitation is resolved in v2.0.0 by moving to hooks
+49
View File
@@ -0,0 +1,49 @@
# Memory Continuity — Marketplace Listing
## What it does
Memory Continuity is a **zero-dependency lifecycle plugin** that preserves your working state across session resets, compaction, and gateway restarts. It answers one question:
> What were we doing, where did we stop, and what should happen next?
## Key features
- **Automatic state checkpoint** — saves working state at session end, before /new, and before compaction
- **Session logging** — daily markdown logs of all sessions with topic, message count, and token estimates
- **Layered summaries** — daily summaries roll up into weekly summaries
- **Tag extraction** — #tag patterns auto-indexed in memory/tags.md
- **Relevance injection** — injects related history at session start based on keyword matching
- **Memory decay** — old archives move to cold storage after configurable days
- **CJK-aware** — proper token estimation for Chinese/Japanese/Korean text
- **Search & recall** — /mc search and /mc recall for cross-memory search
- **Programmatic API** — other plugins can call mc:recall service directly
## Why this plugin?
| Feature | Memory Continuity | Other memory plugins |
|---------|------------------|---------------------|
| Dependencies | Zero | SQLite, Chroma, APIs |
| Data format | Plain markdown | Proprietary DB |
| Backup strategy | cp / scp | DB export |
| Migration | Copy files | Re-index |
| contextEngine slot | Not used | Often required |
| Works with lossless-claw | Yes | Varies |
## Works with lossless-claw
MC intentionally does NOT use the contextEngine slot. It runs via lifecycle hooks only. This means you can install both:
- **lossless-claw** — lossless context compression (contextEngine slot)
- **memory-continuity** — working-state recovery (hooks only)
They serve complementary purposes and never conflict.
## Install
```
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
## Configuration
All settings are optional with sensible defaults. See `openclaw plugins inspect memory-continuity` for the full config schema.
+149 -1
View File
@@ -1,6 +1,6 @@
# memory-continuity # memory-continuity
**Current release:** `v2.1.0` **Current release:** `v4.0.0`
OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction. OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
@@ -46,6 +46,37 @@ Because state injection happens at the hook level (before the model sees anythin
## Quick Start ## Quick Start
### Marketplace Install (recommended)
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
### Works with lossless-claw
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
- **lossless-claw** = lossless context compression (contextEngine slot)
- **memory-continuity** = working-state recovery (hooks only)
Install both for the best experience.
### Programmatic API
Other plugins can call MC's recall function programmatically:
```javascript
// In another plugin's register() function:
const recall = api.getService("mc:recall");
if (recall) {
const result = await recall.handler(
{ topic: "deployment issues", maxItems: 3 },
ctx
);
// result.results = [{ date, type, score, summary }, ...]
}
```
### Install ### Install
```bash ```bash
@@ -69,6 +100,18 @@ No npm install, no API keys, no external database.
### Verify ### Verify
```bash ```bash
# Quick 3-layer install check (files → tool → workspace state)
bash scripts/verify.sh
# Show a sample high-importance state entry
bash scripts/verify.sh --sample
# Check against a custom workspace
bash scripts/verify.sh --workspace ~/.openclaw/workspace/myproject
```
```bash
# Alternatively, confirm the gateway loaded the plugin
openclaw gateway restart 2>&1 | grep memory-continuity openclaw gateway restart 2>&1 | grep memory-continuity
# Should show: [memory-continuity] Plugin registered successfully # Should show: [memory-continuity] Plugin registered successfully
``` ```
@@ -80,6 +123,62 @@ openclaw gateway restart 2>&1 | grep memory-continuity
3. Ask "what was the secret?" or "我们刚才聊到哪了" 3. Ask "what was the secret?" or "我们刚才聊到哪了"
4. The agent should immediately surface the recovered state 4. The agent should immediately surface the recovered state
## Interactive Commands (`/mc`)
v3.0.0 adds a companion plugin that registers `/mc` as a native slash command. Works in Telegram, Discord, and anywhere OpenClaw commands are supported.
```
/mc state View main agent's working state
/mc state --all Overview of all agents
/mc state tech_geek View a specific agent's state
/mc history List archived sessions
/mc restore 3 Restore archive #3
/mc search auth Search "auth" across all memory
/mc settings View plugin settings
/mc settings maxArchiveCount 30 Update a setting
/mc compact Compress oversized state file
/mc export all Export all agents' memory to file
/mc --help Command reference
```
The `/mc` plugin reads memory files directly — no HTTP endpoints, no model invocation. Responses are instant.
### Install the command plugin
The `/mc` command plugin is separate from the lifecycle plugin. To install:
```bash
# Clone (if you haven't already)
cd ~/.openclaw/projects
git clone https://github.com/dtzp555-max/memory-continuity.git
# The mc-plugin is bundled in the mc-plugin/ directory
cp -r memory-continuity/mc-plugin ~/.openclaw/projects/mc-plugin
mkdir -p ~/.openclaw/extensions/mc
cp ~/.openclaw/projects/mc-plugin/* ~/.openclaw/extensions/mc/
```
Then add to `openclaw.json`:
```json
{
"plugins": {
"allow": ["memory-continuity", "mc"],
"entries": {
"mc": { "enabled": true }
},
"installs": {
"mc": {
"source": "path",
"sourcePath": "~/.openclaw/projects/mc-plugin",
"installPath": "~/.openclaw/extensions/mc"
}
}
}
}
```
Restart the gateway and `/mc --help` should work.
## Configuration ## Configuration
The plugin works with zero configuration. Optional settings in `openclaw.json`: The plugin works with zero configuration. Optional settings in `openclaw.json`:
@@ -110,6 +209,7 @@ The plugin works with zero configuration. Optional settings in `openclaw.json`:
| `maxStateLines` | `50` | Max lines for CURRENT_STATE.md | | `maxStateLines` | `50` | Max lines for CURRENT_STATE.md |
| `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` | | `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` |
| `autoExtract` | `true` | Auto-extract state from conversation at session end | | `autoExtract` | `true` | Auto-extract state from conversation at session end |
| `maxArchiveCount` | `20` | Maximum archive files to keep (oldest auto-deleted) |
## The checkpoint file ## The checkpoint file
@@ -157,6 +257,47 @@ scp $WORKSPACE/memory/CURRENT_STATE.md newhost:$WORKSPACE/memory/
No database, no vector embeddings, no API keys to transfer. No database, no vector embeddings, no API keys to transfer.
## Recovery after OpenClaw upgrade
OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the plugin stops working after an upgrade, follow these steps:
### Quick diagnosis
```bash
# Check if the plugin is loaded
openclaw gateway restart 2>&1 | grep memory-continuity
# Expected: [memory-continuity] Plugin registered successfully
# Verify config is intact
cat ~/.openclaw/openclaw.json | grep -A2 memory-continuity
```
### Common issues and fixes
| Symptom | Cause | Fix |
|---------|-------|-----|
| No `Plugin registered` in startup log | Plugin files missing or config lost | Re-run `bash scripts/post-install.sh` |
| `plugins.allow is empty` warning | `plugins.allow` was cleared in config | Add `"plugins.allow": ["memory-continuity"]` |
| `loaded without provenance` warning | `plugins.installs` record missing | Add `"plugins.installs": {"memory-continuity": {"source": "path"}}` |
| New version changed hook API | OpenClaw breaking change | Check [CHANGELOG](CHANGELOG.md), update `index.js` |
| State not recovering, no errors | Session cached stale skillsSnapshot | `/new` to start fresh session, or re-run `post-install.sh` (clears cache) |
### One-command recovery
The install script is idempotent — safe to re-run at any time:
```bash
cd ~/.openclaw/projects/memory-continuity # or wherever you cloned it
git pull # pull latest version
bash scripts/post-install.sh # reinstall + restart gateway
```
### Pre-upgrade backup (recommended)
```bash
cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak
```
## Architecture: Plugin vs Skill ## Architecture: Plugin vs Skill
Previous versions (v0.x) shipped as a **skill** — a markdown file that asked the model to read/write `CURRENT_STATE.md`. This was unreliable because models could ignore the instructions. Previous versions (v0.x) shipped as a **skill** — a markdown file that asked the model to read/write `CURRENT_STATE.md`. This was unreliable because models could ignore the instructions.
@@ -189,6 +330,7 @@ memory-continuity/
│ └── phase2-hook-validation.md │ └── phase2-hook-validation.md
└── scripts/ └── scripts/
├── post-install.sh # Automated installer ├── post-install.sh # Automated installer
├── verify.sh # 3-layer install verifier
└── continuity_doctor.py # Health check └── continuity_doctor.py # Health check
``` ```
@@ -200,6 +342,12 @@ memory-continuity/
4. **Portable and backupable**`cp` is your backup tool 4. **Portable and backupable**`cp` is your backup tool
5. **Complements native OpenClaw memory** — does not replace it 5. **Complements native OpenClaw memory** — does not replace it
## Support
If you find this plugin useful, please consider giving it a ⭐ on GitHub — it helps others discover the project!
[![GitHub stars](https://img.shields.io/github/stars/dtzp555-max/memory-continuity?style=social)](https://github.com/dtzp555-max/memory-continuity)
## License ## License
MIT MIT
+16
View File
@@ -130,6 +130,22 @@ Update the file by **overwriting** it, not appending, at these moments:
| Before handoff / subagent exit | Preserves outputs and unsurfaced results | | Before handoff / subagent exit | Preserves outputs and unsurfaced results |
| After a substantive state change | Keeps checkpoint aligned with actual work | | After a substantive state change | Keeps checkpoint aligned with actual work |
### Override rule
**CURRENT_STATE.md must always be overwritten when:**
- A new task or objective starts — regardless of what is currently in the file
- The previous objective is complete or abandoned
- The user gives a new task that supersedes the previous one
Having content in CURRENT_STATE.md does NOT mean it should be preserved.
Content only matters if Objective is still active and work is genuinely in progress.
Checking before overwrite:
- Read the file
- If Objective matches the current task → update in place (overwrite)
- If Objective does NOT match → overwrite the entire file with the new state
- Never append. Never skip the update because "there's already something there".
### 4. Keep the checkpoint small ### 4. Keep the checkpoint small
`CURRENT_STATE.md` should usually stay under about 40 lines and be readable in `CURRENT_STATE.md` should usually stay under about 40 lines and be readable in
@@ -0,0 +1,566 @@
# v3.1 Short-Term Memory Layer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add session logging, smart tail protection during compaction, CJK token awareness, and noise filtering to the Memory Continuity plugin.
**Architecture:** Four independent features added to the existing lifecycle hook system. Session logs write to `memory/sessions/YYYY-MM-DD.md` (one file per day, append-only). Tail protection injects recent critical messages into `before_compaction`. CJK token estimation uses a helper function for line-count decisions. Ignore patterns filter out cron/subagent noise at `agent_end`.
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `index.js` | Modify | Add session logging in `agent_end`, enhance `before_compaction` with tail protection, add CJK helper, add ignore-pattern filtering |
| `openclaw.plugin.json` | Modify | Add new config keys: `sessionLogging`, `tailProtectCount`, `ignorePatterns` |
| `mc-plugin/index.js` | Modify | Add `/mc sessions` subcommand, update help text |
| `mc-plugin/openclaw.plugin.json` | No change | — |
---
### Task 1: CJK Token Estimation Helper
**Files:**
- Modify: `index.js` (after line 15, the PLACEHOLDER_VALUES block)
- [ ] **Step 1: Add `estimateTokens` helper function to `index.js`**
Add this after the `PLACEHOLDER_VALUES` constant (line 15):
```javascript
/**
* Estimate token count with CJK awareness.
* CJK characters ≈ 1.5 tokens each; Latin words ≈ 1 token per ~4 chars.
*/
function estimateTokens(text) {
if (!text) return 0;
// Count CJK characters (CJK Unified Ideographs + common CJK ranges)
const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g) || []).length;
// Remove CJK chars, count remaining as ~1 token per 4 chars
const nonCjk = text.replace(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g, "");
const latinTokens = Math.ceil(nonCjk.length / 4);
return Math.ceil(cjkCount * 1.5) + latinTokens;
}
```
- [ ] **Step 2: Update `truncate` in `extractStateFromMessages` to use token-aware truncation**
In `extractStateFromMessages` (around line 175), replace the existing `truncate` function:
```javascript
// Token-aware truncation
const truncate = (s, maxTokens = 200) => {
if (estimateTokens(s) <= maxTokens) return s;
// Binary search for the right cut point
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (estimateTokens(s.slice(0, mid)) <= maxTokens) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo) + "...";
};
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: add CJK token-aware estimation helper (v3.1)"
```
---
### Task 2: Ignore Patterns — Filter Cron/Subagent Noise
**Files:**
- Modify: `index.js` (agent_end hook, around line 295)
- Modify: `openclaw.plugin.json` (add `ignorePatterns` config)
- [ ] **Step 1: Add `ignorePatterns` to `openclaw.plugin.json` config schema**
Add after the `maxArchiveCount` property block (after line 29):
```json
"ignorePatterns": {
"type": "array",
"default": [],
"description": "Regex patterns to ignore sessions (e.g. cron jobs, subagent noise). Matched against first user message."
}
```
And add corresponding uiHints after the `maxArchiveCount` hint (after line 47):
```json
"ignorePatterns": {
"label": "Ignore patterns",
"help": "Skip state extraction for sessions matching these patterns (regex)"
}
```
- [ ] **Step 2: Add ignore-pattern check in `agent_end` hook in `index.js`**
In the `agent_end` hook, after the `realUserMsgs` filtering block (after line 321), add:
```javascript
// Check ignore patterns — skip sessions matching cron/subagent noise
const ignorePatterns = (config.ignorePatterns || [])
.map(p => { try { return new RegExp(p, "i"); } catch { return null; } })
.filter(Boolean);
if (ignorePatterns.length > 0 && realUserMsgs.length > 0) {
const firstMsg = realUserMsgs[0];
if (ignorePatterns.some(re => re.test(firstMsg))) {
log.info?.("[memory-continuity] Session matches ignorePattern, skipping");
return;
}
}
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 4: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: add ignorePatterns to filter cron/subagent noise (v3.1)"
```
---
### Task 3: Session Logging
**Files:**
- Modify: `index.js` (add `writeSessionLog` helper + call it in `agent_end`)
- [ ] **Step 1: Add `writeSessionLog` helper function to `index.js`**
Add after the `cleanupMemoryDir` function (after line 140):
```javascript
/**
* Append a session summary to the daily session log.
* File: memory/sessions/YYYY-MM-DD.md (one per day, append-only)
*/
function writeSessionLog(workspaceDir, messages, config = {}) {
if (config.sessionLogging === false) return;
if (!messages || messages.length === 0) return;
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const sessionsDir = path.join(workspaceDir, "memory", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
const logFile = path.join(sessionsDir, `${dateStr}.md`);
// Extract first meaningful user message as topic
let topic = "(no topic)";
for (const msg of messages) {
if (msg?.role !== "user") continue;
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n")
: "";
const cleaned = text
.replace(/^Conversation info \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim();
if (cleaned.length > 10) {
topic = cleaned.split("\n")[0].slice(0, 120);
break;
}
}
// Count messages by role
const userCount = messages.filter(m => m?.role === "user").length;
const assistantCount = messages.filter(m => m?.role === "assistant").length;
const totalTokens = estimateTokens(
messages.map(m => typeof m?.content === "string" ? m.content : "").join("")
);
// Build log entry
const entry = [
`### ${timeStr}`,
`- **Topic:** ${topic}`,
`- **Messages:** ${userCount} user / ${assistantCount} assistant`,
`- **Est. tokens:** ~${totalTokens}`,
"",
].join("\n");
// Append to daily log (create with header if new)
if (!fs.existsSync(logFile)) {
const header = `# Session Log — ${dateStr}\n\n`;
fs.writeFileSync(logFile, header + entry, "utf8");
} else {
fs.appendFileSync(logFile, entry, "utf8");
}
}
```
- [ ] **Step 2: Add `sessionLogging` config to `openclaw.plugin.json`**
Add after the `ignorePatterns` property in the config schema:
```json
"sessionLogging": {
"type": "boolean",
"default": true,
"description": "Write session summaries to memory/sessions/ daily logs"
}
```
And add corresponding uiHints:
```json
"sessionLogging": {
"label": "Session logging",
"help": "Append session summaries to daily markdown logs"
}
```
- [ ] **Step 3: Call `writeSessionLog` from `agent_end` hook**
In the `agent_end` hook, right before the `const existing = readFile(statePath);` line (around line 324), add:
```javascript
// Write session log entry
writeSessionLog(ws, messages, config);
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: add session logging to memory/sessions/ daily files (v3.1)"
```
---
### Task 4: Smart Tail Protection in `before_compaction`
**Files:**
- Modify: `index.js` (enhance `before_compaction` hook, around line 254)
- Modify: `openclaw.plugin.json` (add `tailProtectCount` config)
- [ ] **Step 1: Add `tailProtectCount` config to `openclaw.plugin.json`**
Add after the `sessionLogging` property in the config schema:
```json
"tailProtectCount": {
"type": "number",
"default": 3,
"description": "Number of recent critical message pairs to protect during compaction"
}
```
And add corresponding uiHints:
```json
"tailProtectCount": {
"label": "Tail protect count",
"help": "Keep N recent user/assistant pairs visible after compaction"
}
```
- [ ] **Step 2: Add `extractTailMessages` helper to `index.js`**
Add after the `writeSessionLog` function:
```javascript
/**
* Extract the last N meaningful user/assistant exchange pairs from messages.
* Returns a formatted string for injection into compaction context.
*/
function extractTailMessages(messages, count = 3) {
if (!messages || messages.length === 0) return null;
// Walk backwards, collect up to `count` user+assistant pairs
const pairs = [];
let i = messages.length - 1;
while (i >= 0 && pairs.length < count) {
// Find assistant message
while (i >= 0 && messages[i]?.role !== "assistant") i--;
if (i < 0) break;
const assistantMsg = messages[i];
i--;
// Find preceding user message
while (i >= 0 && messages[i]?.role !== "user") i--;
if (i < 0) break;
const userMsg = messages[i];
i--;
const getText = (msg) => {
if (typeof msg?.content === "string") return msg.content;
if (Array.isArray(msg?.content)) {
return msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n");
}
return "";
};
const userText = getText(userMsg).trim();
const assistantText = getText(assistantMsg).trim();
// Skip trivial exchanges
if (userText.length < 10 && assistantText.length < 20) continue;
// Token-aware truncation per message
const maxPerMsg = 150;
const truncMsg = (s) => {
if (estimateTokens(s) <= maxPerMsg) return s;
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (estimateTokens(s.slice(0, mid)) <= maxPerMsg) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo) + "...";
};
pairs.unshift({ user: truncMsg(userText), assistant: truncMsg(assistantText) });
}
if (pairs.length === 0) return null;
const lines = ["=== RECENT EXCHANGE (protected) ==="];
for (const p of pairs) {
lines.push(`User: ${p.user}`);
lines.push(`Assistant: ${p.assistant}`);
lines.push("---");
}
lines.push("=== END RECENT EXCHANGE ===");
return lines.join("\n");
}
```
- [ ] **Step 3: Enhance `before_compaction` hook to inject tail messages**
Replace the existing `before_compaction` hook body (lines 254-270) with:
```javascript
api.on("before_compaction", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws);
if (!statePath) return;
const md = readFile(statePath);
if (!md) return;
const snapshot = buildSnapshot(md);
if (!snapshot) return;
log.info?.("[memory-continuity] Injecting state before compaction");
// Smart tail protection: also inject recent critical messages
const tailCount = config.tailProtectCount ?? 3;
const messages = _event?.messages;
const tail = tailCount > 0 ? extractTailMessages(messages, tailCount) : null;
const parts = [snapshot];
if (tail) {
parts.push(tail);
log.info?.("[memory-continuity] Tail protection: injected recent exchanges");
}
return {
prependSystemContext: parts.join("\n\n"),
};
}, { priority: 10 });
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: smart tail protection during compaction (v3.1)"
```
---
### Task 5: `/mc sessions` Command
**Files:**
- Modify: `mc-plugin/index.js` (add `cmdSessions` function + wire into switch)
- [ ] **Step 1: Add `cmdSessions` handler to `mc-plugin/index.js`**
Add after the `cmdExport` function (before `cmdHelp`, around line 437):
```javascript
function cmdSessions(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const sessionsDir = path.join(memDir, "sessions");
let files;
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No session logs for "${agent}".`; }
if (!files.length) return `No session logs for "${agent}".`;
// If date specified, show that day's log
if (dateArg) {
const target = `${dateArg}.md`;
const content = readFile(path.join(sessionsDir, target));
if (!content) return `No session log for ${dateArg}.`;
// Truncate to last 50 lines to stay compact
const lines = content.split("\n");
const shown = lines.length > 50 ? lines.slice(-50) : lines;
let out = shown.join("\n");
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
return out;
}
// List recent session logs
let out = `Session Logs: ${agent} (${files.length} days)\n`;
out += "─────────────────────────────\n";
for (const f of files.slice(0, 14)) {
const date = f.replace(".md", "");
const content = readFile(path.join(sessionsDir, f));
// Count session entries (### HH:MM headers)
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
}
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
return out.trimEnd();
}
```
- [ ] **Step 2: Wire `sessions` into the command switch**
In the switch statement (around line 471), add a new case before `help`:
```javascript
case "sessions": text = cmdSessions(subargs || null); break;
```
- [ ] **Step 3: Update `cmdHelp` to include `sessions`**
Add this line in the help text after the `/mc state --all` line:
```
/mc sessions [date] Session logs (daily activity)
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: add /mc sessions command for daily session logs (v3.1)"
```
---
### Task 6: Version Bump and Final Verification
**Files:**
- Modify: `openclaw.plugin.json` (version 3.0.0 → 3.1.0)
- Modify: `mc-plugin/openclaw.plugin.json` (version if present)
- Modify: `package.json` (version if present)
- [ ] **Step 1: Bump version in `openclaw.plugin.json` to 3.1.0**
Change `"version": "3.0.0"` to `"version": "3.1.0"`.
- [ ] **Step 2: Bump version in `package.json` to 3.1.0**
Change `"version"` value to `"3.1.0"`.
- [ ] **Step 3: Verify both plugins parse correctly**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
```
Expected: `All OK`
- [ ] **Step 4: Commit and tag**
```bash
git add openclaw.plugin.json package.json
git commit -m "release: v3.1.0 — short-term memory layer"
```
- [ ] **Step 5: Push to GitHub**
```bash
git push
```
---
## Summary of New Config Keys
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `ignorePatterns` | `string[]` | `[]` | Regex patterns to skip sessions (cron/subagent) |
| `sessionLogging` | `boolean` | `true` | Enable daily session log files |
| `tailProtectCount` | `number` | `3` | Recent exchange pairs to protect during compaction |
## Summary of New/Changed Hooks
| Hook | Change |
|------|--------|
| `agent_end` | +session logging, +ignore pattern filtering, +CJK-aware truncation |
| `before_compaction` | +tail message protection |
## New Command
| Command | Purpose |
|---------|---------|
| `/mc sessions [date]` | List daily session logs or view specific day |
@@ -0,0 +1,674 @@
# v3.2 Mid-Term Memory Layer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add layered markdown summaries (daily/weekly), enhanced cross-source search, and automatic tag extraction to the Memory Continuity plugin.
**Architecture:** Three independent features layered on v3.1's session logging. Daily summaries are generated from session logs at `agent_end` when the day changes. Weekly summaries roll up dailies every Monday. Enhanced search extends the existing `/mc search` to cover sessions + summaries with context lines. Auto-tagging scans session entries for `#tag` patterns and maintains a `memory/tags.md` index. All pure markdown, zero dependencies.
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `index.js` | Modify | Add `generateDailySummary`, `generateWeeklySummary`, call from `agent_end` |
| `openclaw.plugin.json` | Modify | Add `summaryEnabled` config |
| `mc-plugin/index.js` | Modify | Enhance `cmdSearch` to cover sessions+summaries, add `/mc tags` and `/mc summary` commands |
---
### Task 1: Daily Summary Generation
**Files:**
- Modify: `index.js` (add helper + call in agent_end)
- Modify: `openclaw.plugin.json` (add `summaryEnabled` config)
- [ ] **Step 1: Add `summaryEnabled` config to `openclaw.plugin.json`**
Read `openclaw.plugin.json`. Add after the `tailProtectCount` property in configSchema:
```json
"summaryEnabled": {
"type": "boolean",
"default": true,
"description": "Generate daily and weekly summaries from session logs"
}
```
Add corresponding uiHints after the `tailProtectCount` hint:
```json
"summaryEnabled": {
"label": "Summary generation",
"help": "Auto-generate daily/weekly summaries from session logs"
}
```
- [ ] **Step 2: Add `generateDailySummary` helper to `index.js`**
Read `index.js`. Add after the `extractTailMessages` function (ends around line 302), before `extractStateFromMessages`:
```javascript
/**
* Generate a daily summary from the previous day's session log.
* Only runs when today differs from the last summary date.
* Output: memory/summaries/daily/YYYY-MM-DD.md
*/
function generateDailySummary(workspaceDir, config = {}) {
if (config.summaryEnabled === false) return;
const pad = (n) => String(n).padStart(2, "0");
const now = new Date();
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
// Check yesterday's date
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${pad(yesterday.getMonth() + 1)}-${pad(yesterday.getDate())}`;
const summaryDir = path.join(workspaceDir, "memory", "summaries", "daily");
const summaryFile = path.join(summaryDir, `${yStr}.md`);
// Skip if summary already exists for yesterday
if (fs.existsSync(summaryFile)) return;
// Read yesterday's session log
const sessionFile = path.join(workspaceDir, "memory", "sessions", `${yStr}.md`);
const sessionContent = readFile(sessionFile);
if (!sessionContent) return; // No sessions yesterday
// Parse session entries
const entries = sessionContent.split(/^### /gm).filter(e => e.trim());
if (entries.length === 0) return;
const topics = [];
let totalUser = 0;
let totalAssistant = 0;
let totalTokens = 0;
for (const entry of entries) {
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const msgMatch = entry.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
const tokenMatch = entry.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
if (topicMatch) topics.push(topicMatch[1].trim());
if (msgMatch) {
totalUser += parseInt(msgMatch[1]);
totalAssistant += parseInt(msgMatch[2]);
}
if (tokenMatch) totalTokens += parseInt(tokenMatch[1]);
}
// Build daily summary
const summary = [
`# Daily Summary — ${yStr}`,
"",
`**Sessions:** ${entries.length}`,
`**Messages:** ${totalUser} user / ${totalAssistant} assistant`,
`**Est. tokens:** ~${totalTokens}`,
"",
"## Topics",
...topics.map((t, i) => `${i + 1}. ${t}`),
"",
].join("\n");
fs.mkdirSync(summaryDir, { recursive: true });
fs.writeFileSync(summaryFile, summary, "utf8");
}
```
- [ ] **Step 3: Call `generateDailySummary` from `agent_end` hook**
In the `agent_end` hook, right after the `writeSessionLog(ws, messages, config);` line (around line 515), add:
```javascript
// Generate daily summary for previous day if needed
generateDailySummary(ws, config);
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: auto-generate daily summaries from session logs (v3.2)"
```
---
### Task 2: Weekly Summary Generation
**Files:**
- Modify: `index.js` (add `generateWeeklySummary` + call in agent_end)
- [ ] **Step 1: Add `generateWeeklySummary` helper to `index.js`**
Read `index.js`. Add after the `generateDailySummary` function:
```javascript
/**
* Generate a weekly summary by rolling up daily summaries.
* Runs on Monday, summarizing the previous week (Mon-Sun).
* Output: memory/summaries/weekly/YYYY-Www.md (ISO week number)
*/
function generateWeeklySummary(workspaceDir, config = {}) {
if (config.summaryEnabled === false) return;
const now = new Date();
// Only run on Mondays
if (now.getDay() !== 1) return;
const pad = (n) => String(n).padStart(2, "0");
// Calculate previous week's Monday
const prevMonday = new Date(now);
prevMonday.setDate(prevMonday.getDate() - 7);
// ISO week number
const jan1 = new Date(prevMonday.getFullYear(), 0, 1);
const weekNum = Math.ceil(((prevMonday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
const weekLabel = `${prevMonday.getFullYear()}-W${pad(weekNum)}`;
const weeklyDir = path.join(workspaceDir, "memory", "summaries", "weekly");
const weeklyFile = path.join(weeklyDir, `${weekLabel}.md`);
// Skip if already generated
if (fs.existsSync(weeklyFile)) return;
// Collect daily summaries for the 7 days of previous week
const dailyDir = path.join(workspaceDir, "memory", "summaries", "daily");
const dailies = [];
let weekSessions = 0;
let weekUser = 0;
let weekAssistant = 0;
let weekTokens = 0;
const allTopics = [];
for (let d = 0; d < 7; d++) {
const day = new Date(prevMonday);
day.setDate(day.getDate() + d);
const dayStr = `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
const dailyFile = path.join(dailyDir, `${dayStr}.md`);
const content = readFile(dailyFile);
if (!content) continue;
dailies.push(dayStr);
const sessMatch = content.match(/\*\*Sessions:\*\*\s*(\d+)/);
const msgMatch = content.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
const tokenMatch = content.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
if (sessMatch) weekSessions += parseInt(sessMatch[1]);
if (msgMatch) {
weekUser += parseInt(msgMatch[1]);
weekAssistant += parseInt(msgMatch[2]);
}
if (tokenMatch) weekTokens += parseInt(tokenMatch[1]);
// Extract topics
const topicSection = content.split("## Topics")[1];
if (topicSection) {
const topics = topicSection.match(/^\d+\.\s+(.+)$/gm);
if (topics) allTopics.push(...topics.map(t => t.replace(/^\d+\.\s+/, "").trim()));
}
}
if (dailies.length === 0) return;
// Deduplicate topics (keep first occurrence)
const seen = new Set();
const uniqueTopics = allTopics.filter(t => {
const key = t.toLowerCase().slice(0, 50);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const summary = [
`# Weekly Summary — ${weekLabel}`,
`> ${dailies[0]} to ${dailies[dailies.length - 1]}`,
"",
`**Active days:** ${dailies.length}/7`,
`**Total sessions:** ${weekSessions}`,
`**Total messages:** ${weekUser} user / ${weekAssistant} assistant`,
`**Est. total tokens:** ~${weekTokens}`,
"",
"## Key Topics",
...uniqueTopics.slice(0, 20).map((t, i) => `${i + 1}. ${t}`),
"",
].join("\n");
fs.mkdirSync(weeklyDir, { recursive: true });
fs.writeFileSync(weeklyFile, summary, "utf8");
}
```
- [ ] **Step 2: Call `generateWeeklySummary` from `agent_end` hook**
Right after the `generateDailySummary(ws, config);` line, add:
```javascript
generateWeeklySummary(ws, config);
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: auto-generate weekly summaries from daily rollups (v3.2)"
```
---
### Task 3: Auto-Tagging — Extract `#tag` from Session Logs
**Files:**
- Modify: `index.js` (add `updateTagIndex` helper + call in `writeSessionLog`)
- [ ] **Step 1: Add `updateTagIndex` helper to `index.js`**
Read `index.js`. Add after the `generateWeeklySummary` function:
```javascript
/**
* Extract #tags from a session topic and update the tag index.
* Tags file: memory/tags.md — simple markdown index mapping tags to dates.
*/
function updateTagIndex(workspaceDir, topic, dateStr) {
if (!topic) return;
// Extract #tags (word chars + hyphens after #)
const tags = topic.match(/#[\w-]+/g);
if (!tags || tags.length === 0) return;
const tagsFile = path.join(workspaceDir, "memory", "tags.md");
let existing = readFile(tagsFile) || "# Tag Index\n\n";
for (const tag of tags) {
const normalizedTag = tag.toLowerCase();
// Check if this tag+date combo already exists
if (existing.includes(`${normalizedTag}`) && existing.includes(dateStr)) continue;
// Find or create tag section
const tagHeader = `## ${normalizedTag}`;
if (existing.includes(tagHeader)) {
// Append date to existing tag section
existing = existing.replace(
tagHeader,
`${tagHeader}\n- ${dateStr}`
);
} else {
// Add new tag section
existing += `${tagHeader}\n- ${dateStr}\n\n`;
}
}
writeFile(tagsFile, existing);
}
```
- [ ] **Step 2: Call `updateTagIndex` from `writeSessionLog`**
In the `writeSessionLog` function, right after the `fs.appendFileSync(logFile, prefix + entry, "utf8");` line (the last line of the function), add:
```javascript
// Extract and index #tags from the topic
updateTagIndex(workspaceDir, topic, dateStr);
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: auto-extract #tags from sessions to memory/tags.md (v3.2)"
```
---
### Task 4: Enhanced Search — Cross Sessions + Summaries
**Files:**
- Modify: `mc-plugin/index.js` (enhance `cmdSearch`)
- [ ] **Step 1: Enhance `cmdSearch` to search sessions and summaries**
Read `mc-plugin/index.js`. Replace the entire `cmdSearch` function (lines 220-279) with:
```javascript
function cmdSearch(args) {
if (!args) return "Usage: /mc search <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
const parts = args.trim().split(/\s+/);
let keyword, agent;
const agents = discoverAgents();
const agentNames = new Set(agents.map(a => a.name));
if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) {
agent = parts.pop();
keyword = parts.join(" ");
} else {
keyword = parts.join(" ");
agent = null;
}
const re = new RegExp(keyword, "gi");
const results = [];
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
for (const { name, memDir } of searchAgents) {
if (!memDir) continue;
// 1. Search current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && re.test(state)) {
re.lastIndex = 0;
const lines = state.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
}
// 2. Search archives (most recent 30)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 30)) {
re.lastIndex = 0;
const content = readFile(path.join(archiveDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
}
}
} catch {}
// 3. Search session logs (most recent 14 days)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
re.lastIndex = 0;
const content = readFile(path.join(sessionsDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
}
}
} catch {}
// 4. Search summaries (daily + weekly)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 10)) {
re.lastIndex = 0;
const content = readFile(path.join(sumDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
}
}
} catch {}
}
}
if (!results.length) return `No matches for "${keyword}".`;
// Group by type for clearer output
let out = `Search: "${keyword}" (${results.length} hits)\n`;
out += "─────────────────────────────\n";
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
for (const type of typeOrder) {
const group = results.filter(r => r.type === type);
if (group.length === 0) continue;
out += `\n[${typeLabels[type]}]\n`;
for (const r of group.slice(0, 5)) {
out += ` ${r.agent}/${r.source}\n`;
for (const line of r.matches) {
out += ` ${truncate(line.trim(), 75)}\n`;
}
}
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
}
return out.trimEnd();
}
```
- [ ] **Step 2: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
- [ ] **Step 3: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: enhanced search across sessions and summaries (v3.2)"
```
---
### Task 5: `/mc tags` and `/mc summary` Commands
**Files:**
- Modify: `mc-plugin/index.js` (add two command handlers + wire switch + update help)
- [ ] **Step 1: Add `cmdTags` handler to `mc-plugin/index.js`**
Read `mc-plugin/index.js`. Add before the `cmdHelp` function:
```javascript
function cmdTags(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const tagsFile = path.join(memDir, "tags.md");
const content = readFile(tagsFile);
if (!content) return `No tags for "${agent}".`;
// Parse tags and their date counts
const tagSections = content.split(/^## /gm).filter(s => s.trim());
if (tagSections.length === 0) return `No tags for "${agent}".`;
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
out += "─────────────────────────────\n";
const tags = [];
for (const section of tagSections) {
const lines = section.trim().split("\n");
const tag = lines[0].trim();
if (!tag.startsWith("#")) continue;
const dates = lines.filter(l => l.startsWith("- "));
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
}
// Sort by count descending
tags.sort((a, b) => b.count - a.count);
for (const { tag, count, latest } of tags.slice(0, 30)) {
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
}
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
return out.trimEnd();
}
```
- [ ] **Step 2: Add `cmdSummary` handler**
Add after `cmdTags`:
```javascript
function cmdSummary(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
const typeArg = parts.find(p => p === "daily" || p === "weekly");
const dateArg = parts.find(p => /^\d{4}/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
// If specific date given, show that summary
if (dateArg) {
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
const content = readFile(sumFile);
if (!content) return `No ${type} summary for ${dateArg}.`;
return content;
}
// List available summaries
const types = typeArg ? [typeArg] : ["daily", "weekly"];
let out = `Summaries: ${agent}\n`;
out += "─────────────────────────────\n";
for (const type of types) {
const sumDir = path.join(memDir, "summaries", type);
let files;
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { continue; }
if (files.length === 0) continue;
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
for (const f of files.slice(0, 10)) {
const name = f.replace(".md", "");
const content = readFile(path.join(sumDir, f));
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
const sessions = sessMatch ? sessMatch[1] : "?";
out += ` ${name} ${sessions} session(s)\n`;
}
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
}
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
return out.trimEnd();
}
```
- [ ] **Step 3: Wire `tags` and `summary` into the command switch**
In the switch statement, add before the `help` case:
```javascript
case "tags": text = cmdTags(subargs || null); break;
case "summary": text = cmdSummary(subargs || null); break;
```
- [ ] **Step 4: Update `cmdHelp` to include new commands**
Add these lines after `/mc sessions [date]`:
```
/mc summary [daily|weekly] List or view summaries
/mc tags [agent] View tag index
```
- [ ] **Step 5: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
- [ ] **Step 6: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: add /mc tags and /mc summary commands (v3.2)"
```
---
### Task 6: Version Bump and Final Verification
**Files:**
- Modify: `openclaw.plugin.json` (version 3.1.0 → 3.2.0)
- Modify: `package.json` (version 3.1.0 → 3.2.0)
- [ ] **Step 1: Bump version in both files to 3.2.0**
Change `"version": "3.1.0"` to `"version": "3.2.0"` in both `openclaw.plugin.json` and `package.json`.
- [ ] **Step 2: Verify all files parse correctly**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
```
Expected: `All OK`
- [ ] **Step 3: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "release: v3.2.0 — mid-term memory layer"
```
- [ ] **Step 4: Push to GitHub**
```bash
git push
```
---
## Summary of New Features
| Feature | Files | Description |
|---------|-------|-------------|
| Daily summaries | `memory/summaries/daily/YYYY-MM-DD.md` | Auto-generated from previous day's session log |
| Weekly summaries | `memory/summaries/weekly/YYYY-Www.md` | Rolls up daily summaries every Monday |
| Auto-tagging | `memory/tags.md` | Extracts `#tag` from session topics, maintains index |
| Enhanced search | `/mc search` | Now covers state + archives + sessions + summaries, grouped by type |
| `/mc summary` | mc-plugin | List/view daily and weekly summaries |
| `/mc tags` | mc-plugin | View tag index with counts and latest dates |
## New Config Keys
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `summaryEnabled` | `boolean` | `true` | Enable/disable summary generation |
## New Commands
| Command | Purpose |
|---------|---------|
| `/mc summary [daily\|weekly]` | List summaries or view specific one |
| `/mc summary <date>` | View specific daily/weekly summary |
| `/mc tags [agent]` | View tag index |
@@ -0,0 +1,516 @@
# Phase 4 — Ecosystem Integration (v4.0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Package Memory Continuity for OpenClaw marketplace publishing, declare complementary positioning with lossless-claw, and expose a programmatic skill interface so other plugins can call `/mc recall`.
**Architecture:** Three independent additions — (1) marketplace metadata in manifests + README update, (2) interop declaration via `openclaw.plugin.json` `interop` field and docs, (3) a new `api.exposeService()` call in the lifecycle plugin that other plugins can query for recall results without going through the slash command.
**Tech Stack:** Node.js ESM, OpenClaw plugin API (`api.exposeService`, `api.registerCommand`), markdown files, zero external deps.
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `openclaw.plugin.json` | Add marketplace metadata fields (author, license, icon, category, interop) |
| Modify | `package.json` | Bump to 4.0.0, add marketplace fields |
| Modify | `mc-plugin/package.json` | Bump to 2.0.0 |
| Modify | `mc-plugin/openclaw.plugin.json` | Bump to 2.0.0, add marketplace category |
| Modify | `index.js` | Add `api.exposeService("mc:recall", ...)` in `register()` |
| Modify | `README.md` | Update version, add marketplace install section, interop docs, skill API docs |
| Create | `MARKETPLACE.md` | Marketplace listing long description (separate from README for marketplace scraping) |
---
### Task 1: Marketplace Metadata for Lifecycle Plugin
**Files:**
- Modify: `openclaw.plugin.json`
- Modify: `package.json`
The OpenClaw `plugins install` command supports `--marketplace <source>` and `openclaw plugins marketplace list <source>`. The manifest needs enriched metadata for discoverability.
- [ ] **Step 1: Add marketplace fields to `openclaw.plugin.json`**
Open `openclaw.plugin.json` and add the following fields alongside the existing content:
```json
{
"id": "memory-continuity",
"name": "Memory Continuity",
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
"version": "4.0.0",
"author": {
"name": "dtzp555-max",
"url": "https://github.com/dtzp555-max"
},
"license": "MIT",
"repository": "https://github.com/dtzp555-max/memory-continuity",
"category": "memory",
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
"icon": "brain",
"minOpenClawVersion": "2026.3.0",
"source": "https://github.com/dtzp555-max/memory-continuity",
"configSchema": { ... existing configSchema unchanged ... },
"uiHints": { ... existing uiHints unchanged ... }
}
```
Key additions:
- `author` — object with name and URL
- `license` — MIT
- `category` — "memory" (standard marketplace category)
- `tags` — array for search/filter
- `icon` — emoji-name for UI display
- `minOpenClawVersion` — minimum compatible version
- [ ] **Step 2: Update `package.json` to v4.0.0**
```json
{
"name": "memory-continuity",
"version": "4.0.0",
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git"
},
"keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max",
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "memory-continuity",
"pluginManifest": "openclaw.plugin.json"
}
}
```
- [ ] **Step 3: Verify plugin still loads**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 4: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "feat(marketplace): add marketplace metadata to lifecycle plugin manifest"
```
---
### Task 2: Marketplace Metadata for MC Commands Plugin
**Files:**
- Modify: `mc-plugin/openclaw.plugin.json`
- Modify: `mc-plugin/package.json`
- [ ] **Step 1: Add marketplace fields to `mc-plugin/openclaw.plugin.json`**
```json
{
"id": "mc",
"name": "Memory Continuity Commands",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc recall, /mc settings, etc.",
"version": "2.0.0",
"author": {
"name": "dtzp555-max",
"url": "https://github.com/dtzp555-max"
},
"license": "MIT",
"category": "commands",
"tags": ["memory", "commands", "search", "recall"],
"icon": "terminal",
"requires": ["memory-continuity"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"memoryDir": {
"type": "string",
"default": "",
"description": "Override memory directory path (auto-detected from workspace if empty)"
}
}
}
}
```
Key addition: `requires` — declares dependency on the `memory-continuity` lifecycle plugin.
- [ ] **Step 2: Bump `mc-plugin/package.json` to 2.0.0**
```json
{
"name": "mc-plugin",
"version": "2.0.0",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "mc",
"pluginManifest": "openclaw.plugin.json"
}
}
```
- [ ] **Step 3: Verify mc-plugin still loads**
```bash
cd ~/.openclaw/projects/memory-continuity/mc-plugin
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: function`
- [ ] **Step 4: Commit**
```bash
git add mc-plugin/openclaw.plugin.json mc-plugin/package.json
git commit -m "feat(marketplace): add marketplace metadata to mc-plugin manifest"
```
---
### Task 3: lossless-claw Interop Declaration
**Files:**
- Modify: `openclaw.plugin.json` (add `interop` field)
- Create: `MARKETPLACE.md`
The key design point: MC uses lifecycle hooks (not the `contextEngine` slot), so it coexists with lossless-claw. MC handles working-state recovery; lossless-claw handles full context compression. They complement each other.
- [ ] **Step 1: Add `interop` field to `openclaw.plugin.json`**
Add the following field to the root of the manifest (after `minOpenClawVersion`):
```json
"interop": {
"complements": [
{
"id": "lossless-claw",
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
}
],
"conflicts": [],
"slot": "hooks-only",
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
}
```
- [ ] **Step 2: Create `MARKETPLACE.md`**
This file provides the long-form marketplace listing description (separate from README which is for developers).
```markdown
# Memory Continuity — Marketplace Listing
## What it does
Memory Continuity is a **zero-dependency lifecycle plugin** that preserves your working state across session resets, compaction, and gateway restarts. It answers one question:
> What were we doing, where did we stop, and what should happen next?
## Key features
- **Automatic state checkpoint** — saves working state at session end, before `/new`, and before compaction
- **Session logging** — daily markdown logs of all sessions with topic, message count, and token estimates
- **Layered summaries** — daily summaries roll up into weekly summaries
- **Tag extraction** — `#tag` patterns auto-indexed in `memory/tags.md`
- **Relevance injection** — injects related history at session start based on keyword matching
- **Memory decay** — old archives move to cold storage after configurable days
- **CJK-aware** — proper token estimation for Chinese/Japanese/Korean text
- **Search & recall** — `/mc search` and `/mc recall` for cross-memory search
## Why this plugin?
| Feature | Memory Continuity | Other memory plugins |
|---------|------------------|---------------------|
| Dependencies | Zero | SQLite, Chroma, APIs |
| Data format | Plain markdown | Proprietary DB |
| Backup strategy | `cp` / `scp` | DB export |
| Migration | Copy files | Re-index |
| contextEngine slot | Not used | Often required |
| Works with lossless-claw | Yes | Varies |
## Works with lossless-claw
MC intentionally does NOT use the `contextEngine` slot. It runs via lifecycle hooks only. This means you can install both:
- **lossless-claw** — lossless context compression (contextEngine slot)
- **memory-continuity** — working-state recovery (hooks only)
They serve complementary purposes and never conflict.
## Install
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
## Configuration
All settings are optional with sensible defaults. See `openclaw plugins inspect memory-continuity` for the full config schema.
```
- [ ] **Step 3: Commit**
```bash
git add openclaw.plugin.json MARKETPLACE.md
git commit -m "feat(interop): declare lossless-claw complementary positioning + marketplace listing"
```
---
### Task 4: Programmatic Skill Interface — `mc:recall` Service
**Files:**
- Modify: `index.js` (add `api.exposeService` in `register()`)
The goal: other plugins can programmatically call `api.getService("mc:recall")` to get recall results as structured data, without parsing slash command output.
- [ ] **Step 1: Add the `findRelevantHistory` re-export as a service**
At the end of the `register(api)` function in `index.js`, before the final log line, add:
```javascript
// ------------------------------------------------------------------
// SERVICE: mc:recall — programmatic interface for other plugins
// ------------------------------------------------------------------
if (typeof api.exposeService === "function") {
api.exposeService("mc:recall", {
description: "Search memory history by topic keywords. Returns scored results.",
/**
* @param {Object} params
* @param {string} params.topic - Keywords to search for
* @param {number} [params.maxItems=5] - Maximum results to return
* @param {string} [params.format="structured"] - "structured" returns array of objects, "text" returns formatted string
* @returns {{ results: Array<{ date: string, type: string, score: number, summary: string }>, total: number }}
*/
async handler(params, ctx) {
const topic = params?.topic;
if (!topic || typeof topic !== "string") {
return { error: "topic (string) is required", results: [], total: 0 };
}
const ws = ctx?.workspaceDir;
if (!ws) {
return { error: "no workspace context", results: [], total: 0 };
}
const maxItems = Math.min(params?.maxItems ?? 5, 20);
// Reuse the internal findRelevantHistory function
const textResult = findRelevantHistory(ws, topic, maxItems);
if (!textResult) {
return { results: [], total: 0 };
}
// For "text" format, return the raw string (same as before_agent_start injection)
if (params?.format === "text") {
return { text: textResult, total: 1 };
}
// For "structured" format, parse the result into objects
const lines = textResult.split("\n").filter(l => l.startsWith("["));
const results = lines.map(line => {
const dateMatch = line.match(/^\[([^\]]+)\]/);
const summary = line.replace(/^\[[^\]]+\]\s*/, "").trim();
return {
date: dateMatch?.[1] || "unknown",
type: "history",
score: 0,
summary,
};
});
return { results, total: results.length };
},
});
log.info?.("[memory-continuity] Exposed mc:recall service for inter-plugin use");
}
```
- [ ] **Step 2: Verify plugin still loads with the service**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default, typeof m.default.register))"
```
Expected: `OK: object function`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(skill-api): expose mc:recall as programmatic service for inter-plugin use"
```
---
### Task 5: README Update
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update README header and install section**
Update the version line at the top:
```markdown
**Current release:** `v4.0.0`
```
Add after the existing "Quick Start" / "Install" section:
```markdown
### Marketplace Install (recommended)
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
### Works with lossless-claw
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
- **lossless-claw** = lossless context compression (contextEngine slot)
- **memory-continuity** = working-state recovery (hooks only)
Install both for the best experience.
### Programmatic API
Other plugins can call MC's recall function programmatically:
```javascript
// In another plugin's register() function:
const recall = api.getService("mc:recall");
if (recall) {
const result = await recall.handler(
{ topic: "deployment issues", maxItems: 3 },
ctx
);
// result.results = [{ date, type, score, summary }, ...]
}
```
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: update README with marketplace install, interop, and skill API sections"
```
---
### Task 6: Final Version Bump + Verification
**Files:**
- Verify: all modified files
- [ ] **Step 1: Verify all version numbers match**
```bash
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
```
Expected:
- `openclaw.plugin.json``"4.0.0"`
- `package.json``"4.0.0"`
- `mc-plugin/openclaw.plugin.json``"2.0.0"`
- `mc-plugin/package.json``"2.0.0"`
- [ ] **Step 2: Verify both plugins load**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('lifecycle:', typeof m.default))"
node -e "import('./mc-plugin/index.js').then(m => console.log('mc-plugin:', typeof m.default))"
```
Expected:
```
lifecycle: object
mc-plugin: function
```
- [ ] **Step 3: Copy to extensions and restart gateway**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
cp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json ~/.openclaw/extensions/mc/ 2>/dev/null
```
- [ ] **Step 4: Verify with `openclaw plugins inspect`**
```bash
openclaw plugins inspect memory-continuity
```
Expected: shows `Version: 4.0.0` and all 5 hooks.
- [ ] **Step 5: Final commit + tag**
```bash
git add -A
git commit -m "release: v4.0.0 — ecosystem integration (marketplace, interop, skill API)"
git tag v4.0.0
git push origin main --tags
```
---
### Task 7: Deploy to All 3 Servers
**Files:** None (deployment only)
- [ ] **Step 1: Deploy to Cloud (152.67.121.35)**
```bash
scp index.js package.json openclaw.plugin.json MARKETPLACE.md opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 2: Deploy to PI (172.16.2.232)**
```bash
scp index.js package.json openclaw.plugin.json MARKETPLACE.md administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 3: Restart Mac gateway**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &
```
- [ ] **Step 4: Verify all 3 servers**
```bash
grep version ~/.openclaw/extensions/memory-continuity/package.json
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
```
Expected: all show `"version": "4.0.0"`.
@@ -0,0 +1,556 @@
# Phase 5 — Conservative Subagent Support (v5.0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable working-state continuity across parent/child agent boundaries — parent seeds child with structured context, child recovers unsurfaced results back to parent.
**Architecture:** Two new lifecycle hooks added to the existing plugin. (1) Enhance `before_agent_start` to detect forked child sessions and inject the parent's CURRENT_STATE.md as a structured seed. (2) Add a new `subagent_ended` hook that reads the child's workspace CURRENT_STATE.md and merges unsurfaced results into the parent's state. Both features are conservative — they degrade gracefully when context is unavailable and never overwrite parent state destructively.
**Tech Stack:** Node.js ESM, OpenClaw plugin lifecycle hooks (`before_agent_start`, `subagent_ended`), filesystem-based state passing (cross-workspace read), zero external dependencies.
**Research Findings (constraints):**
- `subagent_ended` event provides: `{ targetSessionKey, reason, outcome, error }` and context `{ childSessionKey, requesterSessionKey }`
- No pre-spawn hook exists — parent seeding happens via `before_agent_start` on the child side
- Workspaces are fully isolated — each agent has its own `memory/` directory
- Session key format: `agent:<agentId>:subagent:<uuid>` — parseable to extract `agentId`
- Agent workspace is resolvable via config: `agents.list[].workspace` or default `~/.openclaw/workspace/main`
- `isSubagentSessionKey()` and `parseAgentSessionKey()` are runtime utilities (not available to plugins directly — must parse manually)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `index.js` | Add parent-seed logic to `before_agent_start`, add new `subagent_ended` hook, add helper functions |
| Modify | `openclaw.plugin.json` | Bump to 5.0.0, add new config keys |
| Modify | `package.json` | Bump to 5.0.0 |
---
### Task 1: Add Workspace Resolution Helpers
**Files:**
- Modify: `index.js` (helpers section, before plugin definition)
Two new helper functions are needed: one to detect if the current session is a subagent, and one to resolve a workspace directory from an agent ID.
- [ ] **Step 1: Add `isSubagentSession` helper**
In `index.js`, after the existing `extractStateFromMessages` function (around line 709) and before the `// Plugin Definition` comment, add:
```javascript
/**
* Detect if the current session is a subagent by checking the session key format.
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
*/
function isSubagentSession(ctx) {
const key = ctx?.sessionKey || ctx?.SessionKey || "";
return /^agent:[^:]+:subagent:/.test(key);
}
/**
* Extract the parent agent ID from a subagent session key.
* Session key format: agent:<parentAgentId>:subagent:<uuid>
* Returns null if not a subagent key.
*/
function parseParentAgentId(sessionKey) {
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
return match?.[1] || null;
}
/**
* Resolve workspace directory for a given agent ID.
* Searches the OpenClaw config for agent workspace mappings.
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
* or ~/.openclaw/workspaces/<agentId> for named agents.
*/
function resolveAgentWorkspace(agentId) {
if (!agentId) return null;
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
// Try to read config for explicit workspace mapping
try {
const configPath = path.join(base, "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const agents = config?.agents?.list || [];
const entry = agents.find(a => a.id === agentId || a.name === agentId);
if (entry?.workspace) return entry.workspace;
// Check defaults
if (agentId === "main") {
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
}
} catch {}
// Fallback heuristics
if (agentId === "main") return path.join(base, "workspace", "main");
// Named agents typically use ~/.openclaw/workspaces/<agentId>
const namedWs = path.join(base, "workspaces", agentId);
if (fs.existsSync(namedWs)) return namedWs;
return null;
}
/**
* Extract the Unsurfaced Results section from a state markdown file.
* Returns the raw text content of the section, or null if empty/placeholder.
*/
function extractUnsurfacedResults(md) {
if (!md) return null;
const section = extractSection(md, "Unsurfaced Results");
if (!section || !isMeaningful(section)) return null;
return section;
}
```
- [ ] **Step 2: Verify the file still parses**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): add workspace resolution and session detection helpers"
```
---
### Task 2: Parent→Child Seeding in before_agent_start
**Files:**
- Modify: `index.js` (HOOK 1: before_agent_start)
When a child agent starts (detected via subagent session key format), read the parent agent's CURRENT_STATE.md and inject it as additional context alongside the child's own state.
- [ ] **Step 1: Enhance the `before_agent_start` hook**
In `index.js`, find HOOK 1 (`before_agent_start`). The current code reads the agent's own state and injects it. After the existing relevance injection block (around the `if (config.relevanceInjection !== false)` block), add parent-seed logic:
Find this existing code block:
```javascript
// Relevance injection: find related historical entries
if (config.relevanceInjection !== false) {
const objective = extractSection(md, "Objective");
const maxItems = config.maxRelevanceItems ?? 3;
const history = findRelevantHistory(ws, objective, maxItems);
if (history) {
parts.push(history);
log.info?.("[memory-continuity] Injected relevant history context");
}
}
```
Right after this block (before the `return` statement), insert:
```javascript
// Parent seed: if this is a subagent, inject parent's working state
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
try {
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
const parentAgentId = parseParentAgentId(sessionKey);
if (parentAgentId) {
const parentWs = resolveAgentWorkspace(parentAgentId);
if (parentWs) {
const parentStatePath = resolveStatePath(parentWs);
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
if (parentMd) {
const parentSnapshot = buildSnapshot(parentMd);
if (parentSnapshot) {
parts.push(
"=== PARENT AGENT CONTEXT ===\n" +
"The following is the parent agent's working state. " +
"Use this to understand the broader task context.\n" +
parentSnapshot +
"\n=== END PARENT CONTEXT ==="
);
log.info?.("[memory-continuity] Injected parent state seed for subagent");
}
}
}
}
} catch (err) {
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
}
}
```
- [ ] **Step 2: Verify plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): parent-to-child state seeding in before_agent_start"
```
---
### Task 3: Child→Parent Recovery via subagent_ended Hook
**Files:**
- Modify: `index.js` (add new HOOK 6: subagent_ended)
When a child agent ends, read its CURRENT_STATE.md and check for unsurfaced results. If found, append them to the parent's state file.
- [ ] **Step 1: Add the `subagent_ended` hook**
In `index.js`, find the section after HOOK 5 (`session_end`) and before the SERVICE section. Add a new hook between them:
```javascript
// ------------------------------------------------------------------
// HOOK 6: subagent_ended — recover child's unsurfaced results
// ------------------------------------------------------------------
api.on("subagent_ended", async (event, _ctx) => {
const config = getConfig();
if (config.subagentRecovery === false) return;
const parentWs = _ctx?.workspaceDir;
if (!parentWs) return;
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
if (!childSessionKey) {
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
return;
}
// Only process successful completions (not kills/errors)
const outcome = event?.outcome || "";
const reason = event?.reason || "";
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
return;
}
try {
// Parse child agent ID from session key
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
const childAgentId = childMatch?.[1];
if (!childAgentId) return;
const childWs = resolveAgentWorkspace(childAgentId);
if (!childWs) {
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
return;
}
// Read child's state
const childStatePath = resolveStatePath(childWs);
const childMd = childStatePath ? readFile(childStatePath) : null;
if (!childMd) return;
// Extract unsurfaced results from child
const childUnsurfaced = extractUnsurfacedResults(childMd);
if (!childUnsurfaced) {
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
return;
}
// Also extract child's objective for context
const childObjective = extractSection(childMd, "Objective");
// Read parent's current state
const parentStatePath = resolveStatePath(parentWs);
if (!parentStatePath) return;
let parentMd = readFile(parentStatePath);
if (!parentMd) {
parentMd = STATE_TEMPLATE;
}
// Build the recovery note
const now = new Date().toISOString();
const recoveryNote = [
`[${now}] Subagent "${childAgentId}" completed.`,
childObjective ? ` Task: ${childObjective.split("\n")[0].slice(0, 120)}` : "",
` Result: ${childUnsurfaced.split("\n")[0].slice(0, 200)}`,
].filter(Boolean).join("\n");
// Merge into parent's Unsurfaced Results section
const existingUnsurfaced = extractSection(parentMd, "Unsurfaced Results");
const mergedUnsurfaced = isMeaningful(existingUnsurfaced)
? existingUnsurfaced + "\n" + recoveryNote
: recoveryNote;
// Token-aware truncation of merged results
if (estimateTokens(mergedUnsurfaced) > 500) {
// Keep only the most recent entries (last 500 tokens)
const lines = mergedUnsurfaced.split("\n");
let kept = [];
let tokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const lineTokens = estimateTokens(lines[i]);
if (tokens + lineTokens > 500) break;
kept.unshift(lines[i]);
tokens += lineTokens;
}
const truncated = kept.join("\n");
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + truncated
);
} else {
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + mergedUnsurfaced
);
}
// Update the timestamp
parentMd = parentMd.replace(
/^> Last updated:.*$/m,
`> Last updated: ${now}`
);
writeFile(parentStatePath, parentMd);
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
} catch (err) {
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
}
}, { priority: 50 });
```
- [ ] **Step 2: Verify plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): child-to-parent unsurfaced results recovery via subagent_ended"
```
---
### Task 4: New Config Keys + Manifest Bump
**Files:**
- Modify: `openclaw.plugin.json`
- Modify: `package.json`
- [ ] **Step 1: Add subagent config keys to `openclaw.plugin.json`**
Bump version to `5.0.0`. Add two new config properties inside `configSchema.properties`:
```json
"subagentSeed": {
"type": "boolean",
"default": true,
"description": "Inject parent working state into subagent context at startup"
},
"subagentRecovery": {
"type": "boolean",
"default": true,
"description": "Recover unsurfaced results from completed subagents back to parent"
}
```
Add corresponding `uiHints`:
```json
"subagentSeed": {
"label": "Subagent seeding",
"help": "Give subagents parent context so they understand the broader task"
},
"subagentRecovery": {
"label": "Subagent recovery",
"help": "Pull completed subagent results back into parent state"
}
```
- [ ] **Step 2: Bump `package.json` to 5.0.0**
Change `"version": "4.0.0"` to `"version": "5.0.0"`.
- [ ] **Step 3: Verify JSON validity**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
python3 -m json.tool openclaw.plugin.json > /dev/null && echo "JSON valid"
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected:
```
JSON valid
OK: object
```
- [ ] **Step 4: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "feat(subagent): add subagentSeed and subagentRecovery config keys, bump to v5.0.0"
```
---
### Task 5: Add `/mc subagents` Command
**Files:**
- Modify: `mc-plugin/index.js`
- Modify: `mc-plugin/package.json`
- Modify: `mc-plugin/openclaw.plugin.json`
Add a new `/mc subagents` command that shows subagent state across workspaces.
- [ ] **Step 1: Add `cmdSubagents` function to mc-plugin**
In `mc-plugin/index.js`, before the `cmdHelp` function, add:
```javascript
function cmdSubagents(args) {
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
let out = "Subagent State Overview\n";
out += "═══════════════════════\n\n";
for (const { name, memDir } of agents) {
const statePath = path.join(memDir, "CURRENT_STATE.md");
const content = readFile(statePath);
if (!content) {
out += `[${name}] No state file\n\n`;
continue;
}
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
const objective = objMatch?.[1]?.trim() || "None";
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
const updated = updatedMatch?.[1]?.trim() || "unknown";
out += `[${name}] Updated: ${updated}\n`;
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
if (unsurfaced !== "None") {
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
}
out += "\n";
}
return out.trimEnd();
}
```
- [ ] **Step 2: Register the command in the switch statement**
Find the `switch (subcmd)` block and add a new case before the `help` case:
```javascript
case "subagents": text = cmdSubagents(subargs || null); break;
```
- [ ] **Step 3: Update help text**
In `cmdHelp`, add:
```
/mc subagents Subagent state overview across workspaces
```
- [ ] **Step 4: Bump mc-plugin versions to 2.1.0**
In `mc-plugin/package.json`, change version to `"2.1.0"`.
In `mc-plugin/openclaw.plugin.json`, change version to `"2.1.0"`.
- [ ] **Step 5: Verify mc-plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity/mc-plugin
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: function`
- [ ] **Step 6: Commit**
```bash
git add mc-plugin/
git commit -m "feat(subagent): add /mc subagents command for cross-workspace state view"
```
---
### Task 6: Version Verify + Git + Deploy
**Files:** All modified files
- [ ] **Step 1: Verify all versions**
```bash
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
```
Expected:
```
openclaw.plugin.json: "version": "5.0.0"
package.json: "version": "5.0.0"
mc-plugin/openclaw.plugin.json: "version": "2.1.0"
mc-plugin/package.json: "version": "2.1.0"
```
- [ ] **Step 2: Final commit + tag + push**
```bash
git add -A
git commit -m "release: v5.0.0 — conservative subagent support (parent seed + child recovery)"
git tag v5.0.0
git push origin main --tags
```
- [ ] **Step 3: Deploy to Cloud**
```bash
scp index.js package.json openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 4: Deploy to PI**
```bash
scp index.js package.json openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 5: Deploy to Mac**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &
```
- [ ] **Step 6: Verify all 3 servers**
```bash
grep version ~/.openclaw/extensions/memory-continuity/package.json
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
```
Expected: all show `"version": "5.0.0"`.
+918 -17
View File
File diff suppressed because it is too large Load Diff
+883
View File
@@ -0,0 +1,883 @@
/**
* MC Plugin — registers /mc as a native slash command in OpenClaw gateway.
* Provides interactive commands for the Memory Continuity plugin.
*/
import fs from "node:fs";
import path from "node:path";
// ── Helpers ─────────────────────────────────────────────────────────────
function mono(text) { return "```\n" + text + "\n```"; }
const BASE = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
const MAIN_WS = path.join(BASE, "workspace", "main", "memory");
const EXTRA_WS = path.join(BASE, "workspaces");
/** Discover all agent workspaces that have memory directories */
function discoverAgents() {
const agents = [];
// Main workspace
if (fs.existsSync(path.join(MAIN_WS, "CURRENT_STATE.md"))) {
agents.push({ name: "main", memDir: MAIN_WS });
}
// Sub-agent workspaces
try {
for (const d of fs.readdirSync(EXTRA_WS)) {
if (!/^[\w-]+$/.test(d)) continue;
const memDir = path.join(EXTRA_WS, d, "memory");
if (fs.existsSync(path.join(memDir, "CURRENT_STATE.md"))) {
agents.push({ name: d, memDir });
}
}
} catch {}
return agents;
}
function resolveMemDir(agentName) {
if (!agentName || agentName === "main") return MAIN_WS;
const p = path.join(EXTRA_WS, agentName, "memory");
return fs.existsSync(p) ? p : null;
}
function readFile(fp) {
try { return fs.readFileSync(fp, "utf8"); } catch { return null; }
}
function extractSection(md, heading) {
const re = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "m");
return (md.match(re)?.[1] ?? "").trim();
}
function relTime(dateStr) {
try {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m ago`;
return `${Math.floor(hrs / 24)}d ago`;
} catch { return dateStr || "unknown"; }
}
function truncate(s, max = 120) {
if (!s) return "(empty)";
const line = s.split("\n")[0];
return line.length > max ? line.slice(0, max) + "…" : line;
}
// ── Subcommand handlers ─────────────────────────────────────────────────
function cmdState(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const md = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (!md) return `No state file for "${agent}".`;
const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim();
let out = `State: ${agent}\n`;
out += `Updated: ${relTime(updated)}\n`;
out += "─────────────────────────────\n";
for (const section of ["Objective", "Current Step", "Key Decisions", "Next Action", "Blockers"]) {
const val = extractSection(md, section);
// Compact multi-line values
const compact = val.split("\n").slice(0, 3).join("\n");
out += `${section}:\n ${compact || "(empty)"}\n\n`;
}
return out.trimEnd();
}
function cmdStateAll() {
const agents = discoverAgents();
if (!agents.length) return "No agents with memory found.";
let out = `Memory States (${agents.length} agents)\n`;
out += "─────────────────────────────\n";
for (const { name, memDir } of agents) {
const md = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (!md) { out += `${name.padEnd(20)} (no state)\n`; continue; }
const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim();
const objective = extractSection(md, "Objective");
out += `${name.padEnd(20)} ${relTime(updated).padEnd(10)} ${truncate(objective, 50)}\n`;
}
return out.trimEnd();
}
function cmdHistory(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const archiveDir = path.join(memDir, "session_archive");
let files;
try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No archive for "${agent}".`; }
if (!files.length) return `No archived sessions for "${agent}".`;
let out = `Session Archive: ${agent} (${files.length} entries)\n`;
out += "─────────────────────────────\n";
out += `${"#".padStart(3)} ${"Timestamp".padEnd(20)} Objective\n`;
out += "─".repeat(60) + "\n";
for (let i = 0; i < Math.min(files.length, 20); i++) {
const f = files[i];
const ts = f.replace(".md", "").replace(/_/g, " ");
const md = readFile(path.join(archiveDir, f));
const obj = md ? truncate(extractSection(md, "Objective"), 35) : "?";
out += `${String(i + 1).padStart(3)} ${ts.padEnd(20)} ${obj}\n`;
}
if (files.length > 20) out += `\n ... and ${files.length - 20} more`;
return out.trimEnd();
}
function cmdRestore(args) {
const parts = (args || "").trim().split(/\s+/);
const idx = parseInt(parts[0]);
const agent = parts[1] || "main";
if (isNaN(idx) || idx < 1) return "Usage: /mc restore <N> [agent]\nN = archive number from /mc history";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const archiveDir = path.join(memDir, "session_archive");
let files;
try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No archive for "${agent}".`; }
if (idx > files.length) return `Only ${files.length} archives available.`;
const target = files[idx - 1];
const content = readFile(path.join(archiveDir, target));
if (!content) return `Failed to read archive ${target}.`;
// Backup current state first
const statePath = path.join(memDir, "CURRENT_STATE.md");
const current = readFile(statePath);
if (current) {
const bak = path.join(memDir, "CURRENT_STATE.md.bak");
fs.writeFileSync(bak, current, "utf8");
}
fs.writeFileSync(statePath, content, "utf8");
return `✓ Restored archive #${idx} (${target.replace(".md", "")})\n Previous state backed up to CURRENT_STATE.md.bak`;
}
function cmdClear(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const statePath = path.join(memDir, "CURRENT_STATE.md");
const current = readFile(statePath);
if (current) {
// Archive before clearing
const archiveDir = path.join(memDir, "session_archive");
fs.mkdirSync(archiveDir, { recursive: true });
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`;
fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), current, "utf8");
}
const template = `# Current State
> Last updated: ${new Date().toISOString()}
## Objective
None
## Current Step
None
## Key Decisions
- None
## Next Action
None
## Blockers
None
## Unsurfaced Results
None
`;
fs.writeFileSync(statePath, template, "utf8");
return `✓ State cleared for "${agent}"\n Previous state archived.`;
}
function cmdSearch(args) {
if (!args) return "Usage: /mc search <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
const parts = args.trim().split(/\s+/);
let keyword, agent;
const agents = discoverAgents();
const agentNames = new Set(agents.map(a => a.name));
if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) {
agent = parts.pop();
keyword = parts.join(" ");
} else {
keyword = parts.join(" ");
agent = null;
}
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(escaped, "i");
const results = [];
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
for (const { name, memDir } of searchAgents) {
if (!memDir) continue;
// 1. Search current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && re.test(state)) {
re.lastIndex = 0;
const lines = state.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
}
// 2. Search archives (most recent 30)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 30)) {
re.lastIndex = 0;
const content = readFile(path.join(archiveDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
}
}
} catch {}
// 3. Search session logs (most recent 14 days)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
re.lastIndex = 0;
const content = readFile(path.join(sessionsDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
}
}
} catch {}
// 4. Search summaries (daily + weekly)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 10)) {
re.lastIndex = 0;
const content = readFile(path.join(sumDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
}
}
} catch {}
}
}
if (!results.length) return `No matches for "${keyword}".`;
// Group by type for clearer output
let out = `Search: "${keyword}" (${results.length} hits)\n`;
out += "─────────────────────────────\n";
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
for (const type of typeOrder) {
const group = results.filter(r => r.type === type);
if (group.length === 0) continue;
out += `\n[${typeLabels[type]}]\n`;
for (const r of group.slice(0, 5)) {
out += ` ${r.agent}/${r.source}\n`;
for (const line of r.matches) {
out += ` ${truncate(line.trim(), 75)}\n`;
}
}
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
}
return out.trimEnd();
}
function cmdSettings(args) {
// Read memory-continuity config from openclaw.json
const configPath = path.join(BASE, "openclaw.json");
const raw = readFile(configPath);
if (!raw) return "Cannot read openclaw.json";
let config;
try { config = JSON.parse(raw); } catch { return "Cannot parse openclaw.json"; }
const mcConfig = config?.plugins?.entries?.["memory-continuity"]?.config
|| config?.entries?.["memory-continuity"]?.config
|| {};
if (!args) {
const defaults = {
maxStateLines: { value: mcConfig.maxStateLines ?? 50, desc: "Max lines before compress warning" },
archiveOnNew: { value: mcConfig.archiveOnNew ?? true, desc: "Archive state on /new" },
autoExtract: { value: mcConfig.autoExtract ?? true, desc: "Auto-extract state at agent_end" },
maxArchiveCount: { value: mcConfig.maxArchiveCount ?? 20, desc: "Max archive files kept" },
};
let out = "MC Settings\n─────────────────────────────\n";
for (const [k, v] of Object.entries(defaults)) {
out += `${k.padEnd(18)} ${String(v.value).padStart(6)} ${v.desc}\n`;
}
// Show stats
const agents = discoverAgents();
out += "\nStats:\n";
out += ` Agents with memory: ${agents.length}\n`;
let totalArchives = 0;
for (const { memDir } of agents) {
try {
totalArchives += fs.readdirSync(path.join(memDir, "session_archive")).filter(f => f.endsWith(".md")).length;
} catch {}
}
out += ` Total archives: ${totalArchives}\n`;
return out.trimEnd();
}
// Parse "key value" for settings update
const parts = args.trim().split(/\s+/);
if (parts.length < 2 || parts[0] === "--help" || parts[0] === "-h") {
return "Usage: /mc settings <key> <value>\nKeys: maxStateLines, archiveOnNew, autoExtract, maxArchiveCount";
}
const [key, val] = parts;
const validKeys = ["maxStateLines", "archiveOnNew", "autoExtract", "maxArchiveCount"];
if (!validKeys.includes(key)) return `Unknown key: ${key}\nValid: ${validKeys.join(", ")}`;
let parsed;
if (val === "true") parsed = true;
else if (val === "false") parsed = false;
else if (!isNaN(Number(val))) parsed = Number(val);
else return `Cannot parse value: ${val}`;
// Update openclaw.json
try {
// Ensure path exists
if (!config.entries) config.entries = {};
if (!config.entries["memory-continuity"]) config.entries["memory-continuity"] = { enabled: true };
if (!config.entries["memory-continuity"].config) config.entries["memory-continuity"].config = {};
config.entries["memory-continuity"].config[key] = parsed;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
return `${key} = ${parsed}\n Restart gateway to apply.`;
} catch (e) {
return `✗ Failed to write config: ${e.message}`;
}
}
function cmdCompact(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const statePath = path.join(memDir, "CURRENT_STATE.md");
const md = readFile(statePath);
if (!md) return `No state for "${agent}".`;
const lines = md.split("\n");
const originalLen = lines.length;
// Keep headers and first meaningful line of each section
const compacted = [];
let inSection = false;
let sectionLines = 0;
for (const line of lines) {
if (line.startsWith("# ") || line.startsWith("> Last updated")) {
compacted.push(line);
inSection = false;
} else if (line.startsWith("## ")) {
compacted.push(line);
inSection = true;
sectionLines = 0;
} else if (inSection) {
sectionLines++;
if (sectionLines <= 3 || line.startsWith("- ")) {
compacted.push(line);
}
} else {
compacted.push(line);
}
}
// Update timestamp
const result = compacted.join("\n")
.replace(/^> Last updated:.*$/m, `> Last updated: ${new Date().toISOString()}`);
fs.writeFileSync(statePath, result, "utf8");
return `✓ Compacted "${agent}" state: ${originalLen}${compacted.length} lines`;
}
function cmdExport(args) {
const parts = (args || "").trim().split(/\s+/);
// Parse args: [agent|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--tag #tag]
let agent = "all";
let fromDate = null;
let toDate = null;
let filterTag = null;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "--from" && parts[i + 1]) { fromDate = parts[++i]; continue; }
if (parts[i] === "--to" && parts[i + 1]) { toDate = parts[++i]; continue; }
if (parts[i] === "--tag" && parts[i + 1]) { filterTag = parts[++i].toLowerCase(); continue; }
if (parts[i] && !parts[i].startsWith("-")) agent = parts[i];
}
const agents = agent === "all" ? discoverAgents() : [{ name: agent, memDir: resolveMemDir(agent) }];
if (agents.length === 0 || !agents[0]?.memDir) return `Agent "${agent}" not found.`;
const exportDir = path.join(BASE, "exports");
fs.mkdirSync(exportDir, { recursive: true });
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`;
const exportFile = path.join(exportDir, `mc-export-${stamp}.md`);
let content = `# Memory Continuity Export\n> Exported: ${now.toISOString()}\n`;
if (fromDate || toDate) content += `> Date range: ${fromDate || "start"} to ${toDate || "now"}\n`;
if (filterTag) content += `> Tag filter: ${filterTag}\n`;
content += "\n";
const matchesDateRange = (filename) => {
if (!fromDate && !toDate) return true;
const dateMatch = filename.match(/(\d{4}-\d{2}-\d{2})/);
if (!dateMatch) return true; // Include files without dates
const fileDate = dateMatch[1];
if (fromDate && fileDate < fromDate) return false;
if (toDate && fileDate > toDate) return false;
return true;
};
const matchesTag = (text) => {
if (!filterTag) return true;
return text.toLowerCase().includes(filterTag);
};
for (const { name, memDir } of agents) {
if (!memDir) continue;
content += `---\n# Agent: ${name}\n\n`;
// Current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && matchesTag(state)) {
content += `## Current State\n\n${state}\n\n`;
}
// Archives (filtered)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## Archives (${files.length})\n\n`;
for (const f of files) {
const ac = readFile(path.join(archiveDir, f));
if (ac && matchesTag(ac)) content += `### ${f.replace(".md", "")}\n\n${ac}\n\n`;
}
}
} catch {}
// Session logs (filtered)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## Session Logs (${files.length} days)\n\n`;
for (const f of files) {
const sc = readFile(path.join(sessionsDir, f));
if (sc && matchesTag(sc)) content += `### ${f.replace(".md", "")}\n\n${sc}\n\n`;
}
}
} catch {}
// Summaries (filtered)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## ${sub.charAt(0).toUpperCase() + sub.slice(1)} Summaries (${files.length})\n\n`;
for (const f of files) {
const sm = readFile(path.join(sumDir, f));
if (sm && matchesTag(sm)) content += `### ${f.replace(".md", "")}\n\n${sm}\n\n`;
}
}
} catch {}
}
}
fs.writeFileSync(exportFile, content, "utf8");
const sizeKB = (Buffer.byteLength(content) / 1024).toFixed(1);
return `✓ Exported to:\n ${exportFile}\n ${agents.length} agent(s), ${sizeKB} KB`;
}
function cmdSessions(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const sessionsDir = path.join(memDir, "sessions");
let files;
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No session logs for "${agent}".`; }
if (!files.length) return `No session logs for "${agent}".`;
// If date specified, show that day's log
if (dateArg) {
const target = `${dateArg}.md`;
const content = readFile(path.join(sessionsDir, target));
if (!content) return `No session log for ${dateArg}.`;
// Truncate to last 50 lines to stay compact
const lines = content.split("\n");
const shown = lines.length > 50 ? lines.slice(-50) : lines;
let out = shown.join("\n");
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
return out;
}
// List recent session logs
let out = `Session Logs: ${agent} (${files.length} days)\n`;
out += "─────────────────────────────\n";
for (const f of files.slice(0, 14)) {
const date = f.replace(".md", "");
const content = readFile(path.join(sessionsDir, f));
// Count session entries (### HH:MM headers)
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
}
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
return out.trimEnd();
}
function cmdTags(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const tagsFile = path.join(memDir, "tags.md");
const content = readFile(tagsFile);
if (!content) return `No tags for "${agent}".`;
// Parse tags and their date counts
const tagSections = content.split(/^## /gm).filter(s => s.trim());
if (tagSections.length === 0) return `No tags for "${agent}".`;
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
out += "─────────────────────────────\n";
const tags = [];
for (const section of tagSections) {
const lines = section.trim().split("\n");
const tag = lines[0].trim();
if (!tag.startsWith("#")) continue;
const dates = lines.filter(l => l.startsWith("- "));
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
}
// Sort by count descending
tags.sort((a, b) => b.count - a.count);
for (const { tag, count, latest } of tags.slice(0, 30)) {
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
}
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
return out.trimEnd();
}
function cmdSummary(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
const typeArg = parts.find(p => p === "daily" || p === "weekly");
const dateArg = parts.find(p => /^\d{4}/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
// If specific date given, show that summary
if (dateArg) {
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
const content = readFile(sumFile);
if (!content) return `No ${type} summary for ${dateArg}.`;
return content;
}
// List available summaries
const types = typeArg ? [typeArg] : ["daily", "weekly"];
let out = `Summaries: ${agent}\n`;
out += "─────────────────────────────\n";
for (const type of types) {
const sumDir = path.join(memDir, "summaries", type);
let files;
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { continue; }
if (files.length === 0) continue;
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
for (const f of files.slice(0, 10)) {
const name = f.replace(".md", "");
const content = readFile(path.join(sumDir, f));
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
const sessions = sessMatch ? sessMatch[1] : "?";
out += ` ${name} ${sessions} session(s)\n`;
}
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
}
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
return out.trimEnd();
}
function cmdRecall(args) {
if (!args) return "Usage: /mc recall <topic>\nSearches history and returns the most relevant entries for context injection.";
const topic = args.trim();
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
// Search across all agents' session logs and summaries
const words = topic
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf-]/g, " ")
.split(/\s+/)
.filter(w => w.length > 2)
.map(w => w.toLowerCase());
if (words.length === 0) return "Need more specific topic keywords.";
const scored = [];
for (const { name, memDir } of agents) {
if (!memDir) continue;
// Search session logs
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(sessionsDir, f));
if (!content) continue;
const entries = content.split(/^### /gm).filter(e => e.trim());
for (const entry of entries) {
let score = 0;
const lower = entry.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const time = entry.match(/^(\d{2}:\d{2})/)?.[1] || "";
scored.push({
agent: name,
date: `${f.replace(".md", "")} ${time}`,
score,
text: topicMatch?.[1] || entry.split("\n")[0].slice(0, 80)
});
}
}
}
} catch {}
// Search daily summaries
const dailyDir = path.join(memDir, "summaries", "daily");
try {
const files = fs.readdirSync(dailyDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(dailyDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicsMatch = content.match(/## (?:Key )?Topics\n([\s\S]*?)(?:\n##|$)/);
const topics = topicsMatch ? topicsMatch[1].trim().split("\n").slice(0, 3).join("; ") : "";
scored.push({
agent: name,
date: f.replace(".md", ""),
score,
text: topics || "(daily summary)"
});
}
}
} catch {}
// Search archives
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 20)) {
const content = readFile(path.join(archiveDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const objMatch = content.match(/## Objective\n(.+)/);
scored.push({
agent: name,
date: f.replace(".md", "").replace(/_/g, " "),
score,
text: objMatch?.[1]?.slice(0, 80) || "(archived state)"
});
}
}
} catch {}
}
if (scored.length === 0) return `No relevant history found for "${topic}".`;
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 10);
let out = `Recall: "${topic}" (${scored.length} matches, showing top ${top.length})\n`;
out += "─────────────────────────────\n";
for (const item of top) {
out += `[${item.agent}] ${item.date} (score: ${item.score})\n`;
out += ` ${truncate(item.text, 80)}\n\n`;
}
return out.trimEnd();
}
function cmdSubagents(args) {
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
let out = "Subagent State Overview\n";
out += "═══════════════════════\n\n";
for (const { name, memDir } of agents) {
const statePath = path.join(memDir, "CURRENT_STATE.md");
const content = readFile(statePath);
if (!content) {
out += `[${name}] No state file\n\n`;
continue;
}
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
const objective = objMatch?.[1]?.trim() || "None";
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
const updated = updatedMatch?.[1]?.trim() || "unknown";
out += `[${name}] Updated: ${updated}\n`;
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
if (unsurfaced !== "None") {
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
}
out += "\n";
}
return out.trimEnd();
}
function cmdHelp() {
return `MC Commands (Memory Continuity)
─────────────────────────────
/mc state [agent] View current state (default: main)
/mc state --all Overview of all agents
/mc sessions [date] Session logs (daily activity)
/mc summary [daily|weekly] List or view summaries
/mc tags [agent] View tag index
/mc history [agent] List archived sessions
/mc restore <N> [agent] Restore archive #N
/mc clear [agent] Clear state (archives first)
/mc search <keyword> Search across all memory
/mc settings View MC settings
/mc settings <k> <v> Update a setting
/mc compact [agent] Compress state file
/mc export [agent|all] Export (--from/--to/--tag)
/mc recall <topic> Find relevant history by topic
/mc subagents Subagent state overview across workspaces`;
}
// ── Plugin entry point ──────────────────────────────────────────────────
export default function (api) {
console.log("[mc] MC plugin loading, registering /mc command...");
api.registerCommand({
name: "mc",
description: "Memory Continuity commands — state, history, search, settings, etc.",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const raw = (ctx.args || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
let text;
switch (subcmd) {
case "state":
text = subargs === "--all" ? cmdStateAll() : cmdState(subargs || null);
break;
case "sessions": text = cmdSessions(subargs || null); break;
case "history": text = cmdHistory(subargs || null); break;
case "restore": text = cmdRestore(subargs); break;
case "clear": text = cmdClear(subargs || null); break;
case "search": text = cmdSearch(subargs); break;
case "settings": text = cmdSettings(subargs || null); break;
case "compact": text = cmdCompact(subargs || null); break;
case "export": text = cmdExport(subargs || null); break;
case "tags": text = cmdTags(subargs || null); break;
case "summary": text = cmdSummary(subargs || null); break;
case "recall": text = cmdRecall(subargs); break;
case "subagents": text = cmdSubagents(subargs || null); break;
case "help": case "--help": case "-h": case "":
text = cmdHelp(); break;
default:
text = `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}`;
}
return { text: mono(text) };
} catch (err) {
return { text: `MC error: ${err.message}` };
}
},
});
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "mc",
"name": "Memory Continuity Commands",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc settings, etc.",
"version": "2.1.0",
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
"license": "MIT",
"category": "commands",
"tags": ["memory", "commands", "search", "recall"],
"icon": "terminal",
"requires": ["memory-continuity"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"memoryDir": {
"type": "string",
"default": "",
"description": "Override memory directory path (auto-detected from workspace if empty)"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "mc-plugin",
"version": "2.1.0",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "mc",
"pluginManifest": "openclaw.plugin.json"
}
}
+122 -5
View File
@@ -2,8 +2,26 @@
"id": "memory-continuity", "id": "memory-continuity",
"name": "Memory Continuity", "name": "Memory Continuity",
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.", "description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
"version": "2.2.0", "version": "5.0.1",
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
"license": "MIT",
"repository": "https://github.com/dtzp555-max/memory-continuity",
"category": "memory",
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
"icon": "brain",
"minOpenClawVersion": "2026.3.0",
"source": "https://github.com/dtzp555-max/memory-continuity", "source": "https://github.com/dtzp555-max/memory-continuity",
"interop": {
"complements": [
{
"id": "lossless-claw",
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
}
],
"conflicts": [],
"slot": "hooks-only",
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -22,12 +40,111 @@
"type": "boolean", "type": "boolean",
"default": true, "default": true,
"description": "Auto-extract working state from conversation at agent_end" "description": "Auto-extract working state from conversation at agent_end"
},
"maxArchiveCount": {
"type": "number",
"default": 20,
"description": "Maximum number of archive files to keep in session_archive/"
},
"ignorePatterns": {
"type": "array",
"default": [],
"description": "Regex patterns to ignore sessions (e.g. cron jobs, subagent noise). Matched against first user message."
},
"sessionLogging": {
"type": "boolean",
"default": true,
"description": "Write session summaries to memory/sessions/ daily logs"
},
"tailProtectCount": {
"type": "number",
"default": 3,
"description": "Number of recent critical message pairs to protect during compaction"
},
"summaryEnabled": {
"type": "boolean",
"default": true,
"description": "Generate daily and weekly summaries from session logs"
},
"relevanceInjection": {
"type": "boolean",
"default": true,
"description": "Inject relevant historical context at session start"
},
"maxRelevanceItems": {
"type": "number",
"default": 3,
"description": "Maximum number of relevant history items to inject"
},
"archiveDecayDays": {
"type": "number",
"default": 30,
"description": "Days before archives move to cold storage (0 = disabled)"
},
"subagentSeed": {
"type": "boolean",
"default": true,
"description": "Inject parent working state into subagent context at startup"
},
"subagentRecovery": {
"type": "boolean",
"default": true,
"description": "Recover unsurfaced results from completed subagents back to parent"
} }
} }
}, },
"uiHints": { "uiHints": {
"maxStateLines": { "label": "Max state file lines", "help": "Keep checkpoint small for fast recovery" }, "maxStateLines": {
"archiveOnNew": { "label": "Archive on /new", "help": "Save a snapshot before session reset" }, "label": "Max state file lines",
"autoExtract": { "label": "Auto-extract state", "help": "Automatically save working state when conversation ends" } "help": "Keep checkpoint small for fast recovery"
},
"archiveOnNew": {
"label": "Archive on /new",
"help": "Save a snapshot before session reset"
},
"autoExtract": {
"label": "Auto-extract state",
"help": "Automatically save working state when conversation ends"
},
"maxArchiveCount": {
"label": "Max archive files",
"help": "Old archives are auto-deleted when this limit is reached"
},
"ignorePatterns": {
"label": "Ignore patterns",
"help": "Skip state extraction for sessions matching these patterns (regex)"
},
"sessionLogging": {
"label": "Session logging",
"help": "Append session summaries to daily markdown logs"
},
"tailProtectCount": {
"label": "Tail protect count",
"help": "Keep N recent user/assistant pairs visible after compaction"
},
"summaryEnabled": {
"label": "Summary generation",
"help": "Auto-generate daily/weekly summaries from session logs"
},
"relevanceInjection": {
"label": "Relevance injection",
"help": "Inject related history when starting a new session"
},
"maxRelevanceItems": {
"label": "Max relevance items",
"help": "How many related history entries to inject (2-5)"
},
"archiveDecayDays": {
"label": "Archive decay days",
"help": "Move archives older than N days to cold/ subfolder (0 to disable)"
},
"subagentSeed": {
"label": "Subagent seeding",
"help": "Give subagents parent context so they understand the broader task"
},
"subagentRecovery": {
"label": "Subagent recovery",
"help": "Pull completed subagent results back into parent state"
}
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "memory-continuity", "name": "memory-continuity",
"version": "2.2.0", "version": "5.0.1",
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.", "description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -8,7 +8,7 @@
"type": "git", "type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git" "url": "https://github.com/dtzp555-max/memory-continuity.git"
}, },
"keywords": ["openclaw", "plugin", "memory", "continuity"], "keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max", "author": "dtzp555-max",
"license": "MIT", "license": "MIT",
"openclaw": { "openclaw": {
+158
View File
@@ -0,0 +1,158 @@
# ContextEngine Variant Evaluation
## Status
Decision document. Phase 5 evaluation as outlined in plugin-design.md.
## Executive Summary
**Decision: Do not build a ContextEngine variant.**
Memory Continuity already achieves its core recovery goals through lifecycle hooks and `before_prompt_build`. The contextEngine slot is exclusive, and occupying it would break coexistence with lossless-claw and future context engines -- destroying MC's strongest ecosystem advantage. The marginal gains from `assemble()` and `systemPromptAddition` do not justify the slot cost, especially since `prependSystemContext` via `before_prompt_build` already provides prompt-time injection.
---
## What contextEngine provides vs current hooks approach
### ContextEngine slot capabilities (theoretical)
| Capability | Description |
|---|---|
| `assemble()` | Full control over context assembly -- decides what goes into the prompt, in what order, with what priority |
| `systemPromptAddition` | Guaranteed system prompt injection with engine-level priority |
| Context window management | Direct control over token budgets, message pruning, and compression strategy |
| Turn-level interception | Can modify or rewrite every message before it reaches the model |
### What MC currently uses (hooks-only)
| Capability | Implementation |
|---|---|
| `before_prompt_build` | Injects continuity snapshot via `prependSystemContext` -- works today, confirmed in phase 2 validation |
| `command:new` | Archives checkpoint before `/new` reset |
| `agent_end` | Safety checkpoint at session end |
| `before_compaction` | Protection checkpoint before compaction |
| `subagent_ended` | Child-to-parent result recovery |
| Session logging, summaries, relevance injection | All implemented via hooks without needing the engine slot |
### Gap analysis: what would a ContextEngine variant actually gain?
1. **`assemble()` -- full context control**: MC does not need this. MC's job is to inject a 150-300 token snapshot at startup and protect state at boundaries. It does not need to control the entire context assembly pipeline. That is a context compression concern (lossless-claw's domain).
2. **`systemPromptAddition`**: MC already achieves equivalent functionality via `prependSystemContext` in the `before_prompt_build` hook. Phase 2 validation confirmed this works. The injection is not ContextEngine-exclusive.
3. **Token budget management**: MC's snapshot is deliberately small (~150-300 tokens). It does not need fine-grained token budget control. Oversized state files are handled by the `maxStateLines` config and `mc compact` command.
4. **Turn-level interception**: MC does not need to modify arbitrary turns. Its concern is boundary events (startup, /new, compaction, session end), all of which are already covered by lifecycle hooks.
**Conclusion**: The practical capabilities MC needs are already available through the hooks API. The additional capabilities from the contextEngine slot solve problems MC does not have.
---
## Slot conflict analysis
### The core constraint
OpenClaw's `contextEngine` is an **exclusive slot** -- only one plugin can occupy it at a time.
### Impact on lossless-claw coexistence
MC's `openclaw.plugin.json` explicitly declares:
- `"slot": "hooks-only"`
- `"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."`
- `"complements": [{ "id": "lossless-claw", ... }]`
If MC became a contextEngine:
- Users would be forced to choose between MC and lossless-claw
- Context compression is a broader, more fundamental need than working-state recovery
- Most users who want MC also want context compression -- making them mutually exclusive would reduce adoption of both
### Composite/multi-engine support
Research found **no evidence** of composite engine, engine chaining, or multi-engine support in OpenClaw:
- No `contextEngine` array support in plugin schema
- No engine composition layer in runtime code
- No roadmap references to multi-engine support in available documentation
- The design documents themselves note this as a precondition: "only pursue if... composite-engine support exists"
**That precondition has not been met.**
### Ecosystem risk
MC's hooks-only design is a competitive advantage:
- It is the only memory plugin that explicitly complements rather than competes with context engines
- Converting to a contextEngine would make MC just another exclusive-slot plugin competing for the same position
- The interop declaration in `openclaw.plugin.json` is a trust signal to users that MC respects their plugin choices
---
## Risk/Benefit Matrix
### Building a ContextEngine variant
| Factor | Assessment |
|---|---|
| **Benefit: Better prompt injection** | Low -- `prependSystemContext` already works |
| **Benefit: Full context control** | Irrelevant -- MC does not need context assembly control |
| **Benefit: Token budget awareness** | Minimal -- MC's snapshots are already small by design |
| **Risk: Breaks lossless-claw coexistence** | **Critical** -- destroys MC's key ecosystem advantage |
| **Risk: Reduced adoption** | **High** -- users forced into either/or choice |
| **Risk: Maintenance burden** | Medium -- two codepaths (hooks version + engine version) to maintain |
| **Risk: Feature creep** | High -- engine slot invites scope expansion into context compression territory |
| **Risk: No composite engine fallback** | **Critical** -- if composite support never ships, the variant is permanently exclusive |
### Keeping hooks-only
| Factor | Assessment |
|---|---|
| **Benefit: Coexists with all context engines** | **Critical** -- unique market position |
| **Benefit: Simpler architecture** | High -- one codepath, clear scope boundaries |
| **Benefit: Lower maintenance** | High -- no engine API surface to track |
| **Benefit: Aligned with design principles** | High -- scope.md principle #8: "Ecosystem compatibility matters" |
| **Risk: Missing prompt-time power** | Low -- `before_prompt_build` covers the actual need |
| **Risk: Weaker injection guarantee** | Low -- not observed as a real problem in practice |
---
## Recommendation
**Do not build a ContextEngine variant. Invest in hook-based improvements instead.**
### Rationale
1. The two preconditions from plugin-design.md Phase 5 are both unmet:
- "slot tradeoffs are acceptable" -- they are not; lossless-claw coexistence is too valuable
- "composite-engine support exists" -- it does not
2. MC's actual prompt injection needs are satisfied by `before_prompt_build` + `prependSystemContext`
3. The contextEngine API solves context assembly and compression problems that are outside MC's scope
4. Converting would destroy MC's strongest differentiator: being the only memory plugin that complements rather than competes with context engines
### What to do instead: hook improvements
#### Priority 1 -- Strengthen existing injection
- Validate `prependSystemContext` reliability across OpenClaw versions and model backends
- Add fallback to `before_agent_start` if `before_prompt_build` proves unreliable in edge cases
- Improve snapshot quality (better summarization, freshness labeling)
#### Priority 2 -- Better compaction protection
- Confirm `before_compaction` is truly synchronous (the probe exists but needs production validation)
- Add tail-message protection quality metrics
- Consider `after_compaction` hook for post-compaction state verification
#### Priority 3 -- Smarter relevance injection
- The `relevanceInjection` feature already injects historical context at startup
- Improve relevance scoring without needing engine-level token budget control
- Keep injection budget self-contained (MC manages its own token ceiling)
#### Priority 4 -- Monitor OpenClaw evolution
- Watch for composite-engine or engine-delegation support in future OpenClaw releases
- If OpenClaw adds a way for hooks to register "guaranteed system prompt sections" at engine priority without taking the slot, adopt that immediately
- Re-evaluate this decision if the exclusive-slot constraint changes
---
## Conditions for revisiting this decision
Re-evaluate if ANY of these become true:
1. OpenClaw adds composite/multi-engine support (multiple contextEngines can coexist)
2. OpenClaw adds a "system prompt section" API at engine priority, available to non-engine plugins
3. `before_prompt_build` + `prependSystemContext` proves unreliable in a way that cannot be fixed via hooks
4. A significant user cohort explicitly requests engine-level context control from MC and does not use lossless-claw
Until then, the hooks-only architecture remains correct.
---
## Document history
- 2026-03-31: Initial evaluation. Decision: do not build.
+4 -3
View File
@@ -15,6 +15,7 @@ import sys
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -32,7 +33,7 @@ class Severity:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class DiagnosticReport: class DiagnosticReport:
def __init__(self): def __init__(self):
self.entries: list[tuple[str, str]] = [] self.entries: List[Tuple[str, str]] = []
self._worst = Severity.OK self._worst = Severity.OK
def add(self, severity: str, message: str): def add(self, severity: str, message: str):
@@ -78,7 +79,7 @@ PLACEHOLDER_PATTERNS = [
# Checks # Checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def check_existence(workspace: Path, report: DiagnosticReport) -> Path | None: def check_existence(workspace: Path, report: DiagnosticReport) -> Optional[Path]:
"""Check that memory/CURRENT_STATE.md exists.""" """Check that memory/CURRENT_STATE.md exists."""
state_file = workspace / "memory" / "CURRENT_STATE.md" state_file = workspace / "memory" / "CURRENT_STATE.md"
if not state_file.exists(): if not state_file.exists():
@@ -111,7 +112,7 @@ def check_staleness(state_file: Path, workspace: Path, report: DiagnosticReport)
def check_template_compliance(state_file: Path, report: DiagnosticReport) -> dict: def check_template_compliance(state_file: Path, report: DiagnosticReport) -> dict:
"""Check all required sections are present and not placeholder-only.""" """Check all required sections are present and not placeholder-only."""
content = state_file.read_text(encoding="utf-8") content = state_file.read_text(encoding="utf-8")
sections_found: dict[str, str] = {} sections_found: Dict[str, str] = {}
for section in REQUIRED_SECTIONS: for section in REQUIRED_SECTIONS:
# Match ## Section or ## Section\n # Match ## Section or ## Section\n
+192 -14
View File
@@ -4,7 +4,7 @@
# This script: # This script:
# 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/ # 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/
# 2. Adds the plugin entry to openclaw.json (if not present) # 2. Adds the plugin entry to openclaw.json (if not present)
# 3. Restarts the gateway # 3. Detects OpenClaw agents and installs SKILL.md to selected workspaces
# #
# Usage: # Usage:
# bash scripts/post-install.sh # bash scripts/post-install.sh
@@ -50,16 +50,13 @@ if [[ ! -f "$CONFIG_FILE" ]]; then
echo " WARNING: $CONFIG_FILE not found. Skipping config update." echo " WARNING: $CONFIG_FILE not found. Skipping config update."
echo " You'll need to manually add the plugin entry." echo " You'll need to manually add the plugin entry."
else else
# Check if memory-continuity entry already exists python3 -c "
# Always ensure allow list, install record, and entry are present (idempotent)
python3 -c "
import json import json
path = '$CONFIG_FILE' path = '$CONFIG_FILE'
with open(path) as f: with open(path) as f:
data = json.load(f) data = json.load(f)
if 'plugins' not in data: if 'plugins' not in data:
data['plugins'] = {} data['plugins'] = {}
# Add to plugins.allow so OpenClaw trusts this plugin (no provenance warning)
allow_list = data['plugins'].get('allow', []) allow_list = data['plugins'].get('allow', [])
if 'memory-continuity' not in allow_list: if 'memory-continuity' not in allow_list:
allow_list.append('memory-continuity') allow_list.append('memory-continuity')
@@ -77,7 +74,6 @@ data['plugins']['entries']['memory-continuity'] = {
'autoExtract': True 'autoExtract': True
} }
} }
# Add install record for provenance tracking (eliminates untracked-code warning)
if 'installs' not in data['plugins']: if 'installs' not in data['plugins']:
data['plugins']['installs'] = {} data['plugins']['installs'] = {}
data['plugins']['installs']['memory-continuity'] = { data['plugins']['installs']['memory-continuity'] = {
@@ -91,23 +87,205 @@ print(' Added plugin entry, trust config, and install record')
" 2>/dev/null || echo " WARNING: Could not update config automatically. Add manually." " 2>/dev/null || echo " WARNING: Could not update config automatically. Add manually."
fi fi
# Step 3: Restart gateway # ---------------------------------------------------------------------------
echo "[3/3] Restarting gateway ..." # Step 3: Detect agents and install SKILL.md to selected workspaces
if command -v openclaw &>/dev/null; then # ---------------------------------------------------------------------------
if openclaw gateway status 2>&1 | grep -q "running"; then echo "[3/3] Detecting OpenClaw agents ..."
openclaw gateway restart 2>/dev/null && echo " Gateway restarted" || echo " Gateway restart failed — try: openclaw gateway restart"
GRAY='\033[0;90m'
RST='\033[0m'
# detect_agents outputs lines of: INDEX|ID|DISPLAY_NAME|WORKSPACE_PATH
# Uses python3 to parse openclaw.json; falls back gracefully if unavailable.
detect_agents() {
if [[ ! -f "$CONFIG_FILE" ]]; then
return 1
fi
python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
import json, sys, os
config_file = sys.argv[1]
openclaw_dir = sys.argv[2]
with open(config_file) as f:
data = json.load(f)
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', os.path.join(openclaw_dir, 'workspace', 'main'))
agents = data.get('agents', {}).get('list', [])
seen = set()
idx = 1
for agent in agents:
agent_id = agent.get('id', '')
if not agent_id or agent_id in seen:
continue
seen.add(agent_id)
name = agent.get('name', agent_id)
workspace = agent.get('workspace', default_ws)
# Expand ~ in path
workspace = os.path.expanduser(workspace)
print('{}|{}|{}|{}'.format(idx, agent_id, name, workspace))
idx += 1
PYEOF
}
# Collect ALL detected agents (including non-alive) into arrays
ALL_AGENT_IDS=()
ALL_AGENT_NAMES=()
ALL_AGENT_WORKSPACES=()
if command -v python3 &>/dev/null && [[ -f "$CONFIG_FILE" ]]; then
while IFS='|' read -r idx agent_id agent_name workspace; do
ALL_AGENT_IDS+=("$agent_id")
ALL_AGENT_NAMES+=("$agent_name")
ALL_AGENT_WORKSPACES+=("$workspace")
done < <(detect_agents 2>/dev/null || true)
fi
# Filter to alive agents (workspace directory exists)
AGENT_IDS=()
AGENT_NAMES=()
AGENT_WORKSPACES=()
SKIPPED=0
for i in "${!ALL_AGENT_IDS[@]}"; do
if [[ -d "${ALL_AGENT_WORKSPACES[$i]}" ]]; then
AGENT_IDS+=("${ALL_AGENT_IDS[$i]}")
AGENT_NAMES+=("${ALL_AGENT_NAMES[$i]}")
AGENT_WORKSPACES+=("${ALL_AGENT_WORKSPACES[$i]}")
else else
echo " Gateway not running — start it with: openclaw gateway start" SKIPPED=$((SKIPPED + 1))
fi
done
install_skill_to_workspace() {
local workspace="$1"
local skill_dest="${workspace}/skills/memory-continuity"
mkdir -p "$skill_dest"
cp "$REPO_DIR/SKILL.md" "$skill_dest/SKILL.md"
echo " Installed → ${skill_dest}/SKILL.md"
}
if [[ ${#AGENT_IDS[@]} -eq 0 && ${#ALL_AGENT_IDS[@]} -gt 0 ]]; then
# Config has agents but none are alive
echo ""
echo " Found ${#ALL_AGENT_IDS[@]} agent(s) in config but their workspace directories don't exist yet."
echo " You may need to initialize them first."
echo ""
printf " Enter workspace path to install SKILL.md (or press Enter to skip): "
read -r FALLBACK_WS
if [[ -n "$FALLBACK_WS" ]]; then
FALLBACK_WS="${FALLBACK_WS/#\~/$HOME}"
install_skill_to_workspace "$FALLBACK_WS"
else
echo " Skipped SKILL.md installation."
fi
elif [[ ${#AGENT_IDS[@]} -eq 0 ]]; then
# No agents at all — ask user for a workspace path
echo " No agents detected in openclaw.json (file missing or parse error)."
echo ""
printf " Enter workspace path to install SKILL.md (or press Enter to skip): "
read -r FALLBACK_WS
if [[ -n "$FALLBACK_WS" ]]; then
FALLBACK_WS="${FALLBACK_WS/#\~/$HOME}"
install_skill_to_workspace "$FALLBACK_WS"
else
echo " Skipped SKILL.md installation."
fi fi
else else
echo " openclaw command not found — install OpenClaw first" # Show numbered list — alive agents first, then skipped summary
echo ""
echo " Found ${#AGENT_IDS[@]} alive agent(s):"
for i in "${!AGENT_IDS[@]}"; do
num=$((i + 1))
printf " [%d] %s (%s)\n" "$num" "${AGENT_NAMES[$i]}" "${AGENT_WORKSPACES[$i]}"
done
# Show skipped (non-alive) agents in gray
if [[ $SKIPPED -gt 0 ]]; then
echo ""
printf " ${GRAY}Skipped %d agent(s) with missing workspace directories:${RST}\n" "$SKIPPED"
for i in "${!ALL_AGENT_IDS[@]}"; do
if [[ ! -d "${ALL_AGENT_WORKSPACES[$i]}" ]]; then
printf " ${GRAY} %s (%s)${RST}\n" "${ALL_AGENT_NAMES[$i]}" "${ALL_AGENT_WORKSPACES[$i]}"
fi
done
fi
echo ""
echo " Install to: [A]ll alive agents (default) / [1,2,...] specific / [Q]uit"
printf " > (20s timeout, Enter or no input = All) "
# Auto-select All if stdin is not a tty (pipe/CI) or on timeout
if [[ ! -t 0 ]]; then
SELECTION="A"
echo "(non-interactive: auto-selecting All)"
elif read -t 20 -r SELECTION; then
# User provided input (possibly empty = Enter)
: # SELECTION is set
else
# Timeout — default to All
echo ""
echo " (timeout — defaulting to All)"
SELECTION=""
fi
# Empty input (Enter or timeout) defaults to All
if [[ -z "$SELECTION" ]]; then
SELECTION="A"
fi
SELECTION_UPPER="$(echo "$SELECTION" | tr '[:lower:]' '[:upper:]')"
case "$SELECTION_UPPER" in
A|ALL)
for i in "${!AGENT_IDS[@]}"; do
install_skill_to_workspace "${AGENT_WORKSPACES[$i]}"
done
;;
Q|QUIT)
echo " Skipped SKILL.md installation."
;;
*)
# Parse comma-separated numbers
IFS=',' read -ra PICKS <<< "$SELECTION"
INSTALLED=0
for pick in "${PICKS[@]}"; do
pick="${pick// /}" # trim spaces
if [[ "$pick" =~ ^[0-9]+$ ]]; then
idx=$((pick - 1))
if [[ $idx -ge 0 && $idx -lt ${#AGENT_IDS[@]} ]]; then
install_skill_to_workspace "${AGENT_WORKSPACES[$idx]}"
INSTALLED=$((INSTALLED + 1))
else
echo " WARNING: No agent at index $pick — skipped."
fi
else
echo " WARNING: Invalid selection '$pick' — skipped."
fi
done
if [[ $INSTALLED -eq 0 ]]; then
echo " No valid selections — SKILL.md not installed."
fi
;;
esac
fi fi
echo ""
echo "🧙 启动配置向导..."
python3 "$(dirname "$0")/setup_wizard.py" || true
echo "" echo ""
echo "=== Installation complete ===" echo "=== Installation complete ==="
echo "" echo ""
echo "Verify with:" echo "Verify with:"
echo " openclaw gateway restart 2>&1 | grep memory-continuity" echo " bash scripts/verify.sh"
echo " bash scripts/verify.sh --all-agents"
echo "" echo ""
echo "Test with:" echo "Test with:"
echo " 1. Tell your agent something memorable" echo " 1. Tell your agent something memorable"
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env python3
# setup_wizard.py — Interactive post-install wizard for memory-continuity skill
#
# Guides the user through:
# Step 1/4: Select default model
# Step 2/4: Configure per-agent models
# Step 3/4: Review changes + OCP check
# Step 4/4: Restart gateway
#
# Usage:
# python3 scripts/setup_wizard.py
#
# Requires Python 3.6+
import sys
import os
import json
import subprocess
import termios
import tty
import signal
# ---------------------------------------------------------------------------
# Terminal colors (ANSI)
# ---------------------------------------------------------------------------
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
RED = "\033[31m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def green(s): return f"{GREEN}{s}{RESET}"
def yellow(s): return f"{YELLOW}{s}{RESET}"
def cyan(s): return f"{CYAN}{s}{RESET}"
def red(s): return f"{RED}{s}{RESET}"
def bold(s): return f"{BOLD}{s}{RESET}"
def dim(s): return f"{DIM}{s}{RESET}"
# ---------------------------------------------------------------------------
# Key reading (raw terminal, no curses dependency)
# ---------------------------------------------------------------------------
UP_ARROW = "UP"
DOWN_ARROW = "DOWN"
ENTER_KEY = "ENTER"
CTRL_C = "CTRL_C"
def _read_key():
"""Read a single keypress from stdin. Returns a key name string."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x03":
return CTRL_C
if ch in ("\r", "\n"):
return ENTER_KEY
if ch == "\x1b":
# Escape sequence — read more
ch2 = sys.stdin.read(1)
if ch2 == "[":
ch3 = sys.stdin.read(1)
if ch3 == "A":
return UP_ARROW
if ch3 == "B":
return DOWN_ARROW
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ---------------------------------------------------------------------------
# Clean exit on Ctrl+C anywhere
# ---------------------------------------------------------------------------
def _handle_sigint(sig, frame):
print(f"\n\n{yellow('已取消,未做任何修改')}")
sys.exit(0)
signal.signal(signal.SIGINT, _handle_sigint)
# ---------------------------------------------------------------------------
# Arrow-key menu selector
# ---------------------------------------------------------------------------
def arrow_select(prompt, options, default_index=0):
"""
Display a list of options with arrow-key navigation.
Returns the selected index, or raises SystemExit on Ctrl+C.
"""
selected = default_index
n = len(options)
# Hide cursor
sys.stdout.write("\033[?25l")
sys.stdout.flush()
def _render():
# Move cursor up to re-draw
sys.stdout.write(f"\033[{n}A\r")
for i, opt in enumerate(options):
if i == selected:
prefix = f" {CYAN}{BOLD}"
suffix = RESET
else:
prefix = " "
suffix = ""
sys.stdout.write(f"\r{prefix}{opt}{suffix}\n")
sys.stdout.flush()
# Initial render
if prompt:
print(prompt)
for i, opt in enumerate(options):
print(f" {opt}")
try:
while True:
_render()
key = _read_key()
if key == CTRL_C:
sys.stdout.write("\033[?25h")
sys.stdout.flush()
print(f"\n\n{yellow('已取消,未做任何修改')}")
sys.exit(0)
elif key == UP_ARROW:
selected = (selected - 1) % n
elif key == DOWN_ARROW:
selected = (selected + 1) % n
elif key == ENTER_KEY:
break
finally:
sys.stdout.write("\033[?25h")
sys.stdout.flush()
return selected
# ---------------------------------------------------------------------------
# Yes/No prompt
# ---------------------------------------------------------------------------
def yes_no(prompt, default_yes=True):
"""Ask a yes/no question. Returns True for yes."""
hint = "[Y/n]" if default_yes else "[y/N]"
while True:
try:
ans = input(f"{prompt} {hint}: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(f"\n\n{yellow('已取消,未做任何修改')}")
sys.exit(0)
if ans == "":
return default_yes
if ans in ("y", "yes"):
return True
if ans in ("n", "no"):
return False
print(yellow(" 请输入 Y 或 N"))
# ---------------------------------------------------------------------------
# Progress header
# ---------------------------------------------------------------------------
def print_step(n, total, title):
print()
print(bold(cyan(f"步骤 {n}/{total}: {title}")))
print(cyan("" * 56))
# ---------------------------------------------------------------------------
# Find openclaw.json
# ---------------------------------------------------------------------------
OPENCLAW_JSON_PATHS = [
os.path.expanduser("~/.openclaw/openclaw.json"),
"/etc/openclaw/openclaw.json",
]
def find_openclaw_json():
for p in OPENCLAW_JSON_PATHS:
if os.path.isfile(p):
return p
return None
def load_config(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def save_config(path, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
# ---------------------------------------------------------------------------
# Model list
# ---------------------------------------------------------------------------
MODELS = [
("claude-sonnet-4-6", "推荐:综合能力最强,速度均衡"),
("claude-opus-4-6", "能力最强,适合复杂任务,较慢"),
("claude-haiku-4-5", "速度最快,适合简单任务"),
]
KEEP_CURRENT = "(保持当前设置)"
KEEP_UNCHANGED = "(保持不变)"
def model_display_options(include_keep_current=False, include_keep_unchanged=False):
lines = []
for m, desc in MODELS:
lines.append(f"{m:<25} {dim(desc)}")
if include_keep_current:
lines.append(KEEP_CURRENT)
if include_keep_unchanged:
lines.append(KEEP_UNCHANGED)
return lines
def model_values(include_keep_current=False, include_keep_unchanged=False):
vals = [m for m, _ in MODELS]
if include_keep_current:
vals.append(None) # None = keep current
if include_keep_unchanged:
vals.append(None)
return vals
# ---------------------------------------------------------------------------
# Step 1: Default model selection
# ---------------------------------------------------------------------------
def step1_default_model(config):
print_step(1, 4, "选择默认 Model")
print()
print("┌─────────────────────────────────────────────────────┐")
print("│ 选择 OpenClaw 默认 Model │")
print("│ │")
print("│ claude-sonnet-4-6 ← 推荐:综合能力最强,速度均衡 │")
print("│ claude-opus-4-6 能力最强,适合复杂任务,较慢 │")
print("│ claude-haiku-4-5 速度最快,适合简单任务 │")
print("│ (保持当前设置) │")
print("└─────────────────────────────────────────────────────┘")
print()
current = (config
.get("agents", {})
.get("defaults", {})
.get("model", {})
.get("primary", None))
if current:
print(f" {dim('当前默认 Model:')} {cyan(current)}")
else:
print(f" {dim('当前默认 Model:')} {yellow('(未设置)')}")
print()
# Find default selection index
default_idx = len(MODELS) # default: keep current
if current:
for i, (m, _) in enumerate(MODELS):
if m == current:
default_idx = i
break
options = model_display_options(include_keep_current=True)
print(f" {bold('使用 ↑↓ 方向键选择,Enter 确认:')}")
print()
idx = arrow_select(None, options, default_index=default_idx)
if idx >= len(MODELS):
# Keep current
chosen_model = current # may be None
print(f"\n {green('')} 保持当前设置: {cyan(current) if current else yellow('(未设置)')}")
return chosen_model, False # (value, changed)
else:
chosen_model = MODELS[idx][0]
changed = (chosen_model != current)
print(f"\n {green('')} 选择: {cyan(chosen_model)}")
return chosen_model, changed
# ---------------------------------------------------------------------------
# Step 2: Agent model configuration
# ---------------------------------------------------------------------------
def step2_agent_config(config, default_model):
print_step(2, 4, "Agent Model 配置")
print()
agent_options = [
"不修改任何 Agent(默认)",
"全部按默认 Model 修改",
"逐一配置每个 Agent",
]
print(f" {bold('选择配置方式:')}")
print()
mode_idx = arrow_select(None, agent_options, default_index=0)
print()
agents = config.get("agents", {}).get("list", [])
agent_changes = {} # agent_id -> {"primary": ..., "fallback": ...}
if mode_idx == 0:
print(f" {green('')} 不修改任何 Agent")
return agent_changes
if mode_idx == 1:
# Apply default model to all agents
if not default_model:
print(f" {yellow('⚠️ 未设置默认 Model,跳过批量修改')}")
return agent_changes
for agent in agents:
aid = agent.get("id", "")
if not aid:
continue
current_primary = (agent.get("model", {}) or {}).get("primary", None)
if current_primary != default_model:
agent_changes[aid] = {"primary": default_model, "fallback": None, "keep_fallback": True}
print(f" {green('')} 已将 {len(agent_changes)} 个 Agent 的 primary 设为 {cyan(default_model)}")
return agent_changes
# mode_idx == 2: configure one by one
if not agents:
print(f" {yellow('openclaw.json 中没有找到 Agent 列表')}")
return agent_changes
for agent in agents:
aid = agent.get("id", "")
aname = agent.get("name", aid)
if not aid:
continue
cur_model_block = agent.get("model", {}) or {}
cur_primary = cur_model_block.get("primary", None)
cur_fallback = cur_model_block.get("fallback", None)
print()
print(f" {bold(cyan('Agent:'))} {aname} ({dim(aid)})")
print(f" current primary: {cyan(cur_primary) if cur_primary else yellow('(未设置)')}")
print(f" current fallback: {cyan(cur_fallback) if cur_fallback else yellow('(未设置)')}")
print()
# Pick primary
print(f" {bold('选择新 primary Model (↑↓ Enter):')}")
print()
primary_options = model_display_options(include_keep_unchanged=True)
primary_vals = model_values(include_keep_unchanged=True)
# Default: keep unchanged
p_default = len(MODELS)
if cur_primary:
for i, (m, _) in enumerate(MODELS):
if m == cur_primary:
p_default = i
break
p_idx = arrow_select(None, primary_options, default_index=p_default)
new_primary = primary_vals[p_idx] if p_idx < len(MODELS) else cur_primary
print(f"\n {green('')} primary: {cyan(new_primary) if new_primary else yellow('(保持不变)')}")
# Pick fallback
print()
print(f" {bold('选择新 fallback Model (↑↓ Enter):')}")
print()
fallback_options = model_display_options() + ["(跳过 / 不设置 fallback"]
f_default = len(MODELS) # skip
if cur_fallback:
for i, (m, _) in enumerate(MODELS):
if m == cur_fallback:
f_default = i
break
f_idx = arrow_select(None, fallback_options, default_index=f_default)
if f_idx < len(MODELS):
new_fallback = MODELS[f_idx][0]
else:
new_fallback = cur_fallback # keep existing
print(f"\n {green('')} fallback: {cyan(new_fallback) if new_fallback else yellow('(保持不变)')}")
# Record changes only if something actually differs
primary_changed = (new_primary is not None and new_primary != cur_primary)
fallback_changed = (new_fallback is not None and new_fallback != cur_fallback)
if primary_changed or fallback_changed:
agent_changes[aid] = {
"primary": new_primary if primary_changed else cur_primary,
"fallback": new_fallback if fallback_changed else cur_fallback,
}
# Ask whether to continue to next agent
print()
try:
cont = input(f" 还需要修改其他 Agent 吗?[Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(f"\n\n{yellow('已取消,未做任何修改')}")
sys.exit(0)
if cont in ("n", "no"):
break
return agent_changes
# ---------------------------------------------------------------------------
# Step 3: Summary + OCP check + confirm
# ---------------------------------------------------------------------------
OCP_MODELS_NEEDING_PROXY = ("claude-local", "claude-sonnet", "claude-opus", "claude-haiku")
def _needs_ocp(model_name):
if not model_name:
return False
return any(kw in model_name for kw in OCP_MODELS_NEEDING_PROXY)
def _ocp_running():
try:
result = subprocess.run(
["curl", "-s", "--max-time", "3", "http://localhost:3456/health"],
capture_output=True, text=True
)
return result.returncode == 0 and result.stdout.strip() != ""
except Exception:
return False
def step3_summary_confirm(config, new_default_model, default_changed, agent_changes):
print_step(3, 4, "确认修改")
print()
# Build summary lines
current_default = (config
.get("agents", {})
.get("defaults", {})
.get("model", {})
.get("primary", None))
has_any_change = default_changed or bool(agent_changes)
if not has_any_change:
return False # caller will handle "no changes" message
print(f" {bold('即将应用以下修改:')}")
print()
if default_changed:
old_str = cyan(current_default) if current_default else yellow("(未设置)")
new_str = cyan(new_default_model)
print(f" {bold('[defaults]')} {old_str}{new_str}")
agents = config.get("agents", {}).get("list", [])
agent_map = {a.get("id", ""): a for a in agents if a.get("id")}
for aid, change in agent_changes.items():
agent = agent_map.get(aid, {})
cur_primary = (agent.get("model", {}) or {}).get("primary", None)
new_primary = change.get("primary", cur_primary)
old_str = cyan(cur_primary) if cur_primary else yellow("(未设置)")
new_str = cyan(new_primary) if new_primary else yellow("(未设置)")
aname = agent.get("name", aid)
if cur_primary == new_primary:
print(f" {bold(aname + ':')}{'':4}不变")
else:
print(f" {bold(aname + ':')}{'':4}{old_str}{new_str}")
print()
# OCP check
all_new_models = []
if default_changed and new_default_model:
all_new_models.append(new_default_model)
for aid, change in agent_changes.items():
if change.get("primary"):
all_new_models.append(change["primary"])
if change.get("fallback"):
all_new_models.append(change["fallback"])
needs_ocp = any(_needs_ocp(m) for m in all_new_models)
if needs_ocp and not _ocp_running():
print(f" {yellow('⚠️ 检测到你选择的 Model 需要 openclaw-claude-proxy (ocp)')}")
print(f" {yellow(' 但 ocp 目前未运行。建议先安装并启动 ocp。')}")
print()
try:
ans = input(f" 继续 [c] / 退出查看 ocp 安装文档 [q]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(f"\n\n{yellow('已取消,未做任何修改')}")
sys.exit(0)
if ans in ("q", "quit", "exit"):
print()
print(f" {cyan('请参考 ocp 安装文档: https://github.com/openclaw/openclaw-claude-proxy')}")
print(f" {yellow('已退出,未做任何修改')}")
sys.exit(0)
confirmed = yes_no(" 确认应用?", default_yes=True)
return confirmed
# ---------------------------------------------------------------------------
# Apply changes to openclaw.json
# ---------------------------------------------------------------------------
def apply_changes(config, new_default_model, default_changed, agent_changes):
if default_changed and new_default_model is not None:
agents_block = config.setdefault("agents", {})
defaults_block = agents_block.setdefault("defaults", {})
model_block = defaults_block.setdefault("model", {})
model_block["primary"] = new_default_model
if agent_changes:
agents_list = config.get("agents", {}).get("list", [])
for agent in agents_list:
aid = agent.get("id", "")
if aid in agent_changes:
change = agent_changes[aid]
if "model" not in agent or agent["model"] is None:
agent["model"] = {}
if change.get("primary") is not None:
agent["model"]["primary"] = change["primary"]
if change.get("fallback") is not None:
agent["model"]["fallback"] = change["fallback"]
# ---------------------------------------------------------------------------
# Step 4: Restart gateway
# ---------------------------------------------------------------------------
def step4_restart_gateway():
print_step(4, 4, "重启 Gateway")
print()
restart = yes_no(" 配置已更新。是否立即重启 Gateway 以使配置生效?", default_yes=True)
if not restart:
print(f" {dim('跳过重启。请手动运行: openclaw gateway restart')}")
return
print(f" {cyan('正在执行: openclaw gateway restart ...')}")
try:
result = subprocess.run(
["openclaw", "gateway", "restart"],
capture_output=True, text=True
)
if result.returncode == 0:
print(f" {green('✓ Gateway 重启成功')}")
if result.stdout.strip():
print(f" {dim(result.stdout.strip())}")
else:
print(f" {yellow('⚠️ Gateway 重启返回非零退出码')}")
if result.stderr.strip():
print(f" {red(result.stderr.strip())}")
except FileNotFoundError:
print(f" {yellow('⚠️ 未找到 openclaw 命令。请手动重启 Gateway。')}")
except Exception as e:
print(f" {red(f'重启失败: {e}')}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print()
print(bold(cyan("╔══════════════════════════════════════════════════════╗")))
print(bold(cyan("║ OpenClaw Memory-Continuity 配置向导 ║")))
print(bold(cyan("╚══════════════════════════════════════════════════════╝")))
print()
# Check if stdin is a tty — if not, skip interactive wizard
if not sys.stdin.isatty():
print(f" {yellow('非交互式终端,跳过配置向导')}")
sys.exit(0)
# Find config file
config_path = find_openclaw_json()
if not config_path:
print(f" {red('错误: 未找到 openclaw.json')}")
print(f" {dim('已搜索以下路径:')}")
for p in OPENCLAW_JSON_PATHS:
print(f" {dim(p)}")
print(f" {yellow('请先初始化 OpenClaw 配置,再运行此向导')}")
sys.exit(1)
print(f" {dim('配置文件:')} {cyan(config_path)}")
config = load_config(config_path)
# ── Step 1 ──────────────────────────────────────────────────────────────
new_default_model, default_changed = step1_default_model(config)
# ── Step 2 ──────────────────────────────────────────────────────────────
agent_changes = step2_agent_config(config, new_default_model)
# ── Check for any changes ───────────────────────────────────────────────
has_any_change = default_changed or bool(agent_changes)
if not has_any_change:
print()
print(f" {green('无需修改,配置保持不变 ✅')}")
print()
sys.exit(0)
# ── Step 3 ──────────────────────────────────────────────────────────────
confirmed = step3_summary_confirm(
config, new_default_model, default_changed, agent_changes
)
if not confirmed:
print()
print(f" {yellow('已取消,未做任何修改')}")
print()
sys.exit(0)
# Write changes
apply_changes(config, new_default_model, default_changed, agent_changes)
save_config(config_path, config)
print(f"\n {green('✓ 配置已写入')} {cyan(config_path)}")
# ── Step 4 ──────────────────────────────────────────────────────────────
step4_restart_gateway()
print()
print(bold(green("╔══════════════════════════════════════════════════════╗")))
print(bold(green("║ 配置向导完成 ✅ ║")))
print(bold(green("╚══════════════════════════════════════════════════════╝")))
print()
if __name__ == "__main__":
main()
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env bash
# verify.sh — Verify memory-continuity installation (3-layer check)
#
# Usage:
# bash scripts/verify.sh [--workspace PATH] [--sample] [--all-agents]
#
# Options:
# --workspace PATH Override default workspace (~/.openclaw/workspace/main)
# --sample Show a sample high-importance CURRENT_STATE.md
# --all-agents Run 3-layer check across all detected agent workspaces
set -euo pipefail
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
ok() { echo -e " ${GREEN}$*${RESET}"; }
warn() { echo -e " ${YELLOW}⚠️ $*${RESET}"; }
fail() { echo -e " ${RED}$*${RESET}"; }
info() { echo -e " ${CYAN}$*${RESET}"; }
header() { echo -e "\n${BOLD}$*${RESET}"; }
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
WORKSPACE="${HOME}/.openclaw/workspace/main"
WORKSPACE_EXPLICIT=false
SHOW_SAMPLE=false
ALL_AGENTS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--workspace)
WORKSPACE="$2"; WORKSPACE_EXPLICIT=true; shift 2 ;;
--sample)
SHOW_SAMPLE=true; shift ;;
--all-agents)
ALL_AGENTS=true; shift ;;
*)
echo "Unknown argument: $1" >&2; exit 1 ;;
esac
done
# Expand ~ in workspace path
WORKSPACE="${WORKSPACE/#\~/$HOME}"
# ---------------------------------------------------------------------------
# --sample: print example state and exit
# ---------------------------------------------------------------------------
if $SHOW_SAMPLE; then
echo -e "\n${BOLD}Sample CURRENT_STATE.md (high-importance production task):${RESET}\n"
cat <<'EOF'
# Current State
> Last updated: 2026-03-19T11:42:00Z
## Objective
Complete production database migration v2→v3 for user activity table (sharded, ~80M rows).
## Current Step
Migration script written and reviewed. Dry-run on staging passed.
**Not yet tested against production replica.**
## Key Decisions
- Using online schema change (pt-online-schema-change) to avoid table lock
- Backfill batch size: 5,000 rows / 500ms to stay under replication lag threshold
- Rollback plan: swap back via feature flag, no destructive drop until T+48h
## Next Action
Schedule test run against prod replica — waiting for Tao to confirm migration
window (UTC+10, Thursday 23:00). Do NOT proceed without explicit sign-off.
## Blockers
- ⚠️ BLOCKED: Tao hasn't confirmed migration window (UTC+10 Thu 23:00)
- ⚠️ Prod replica test not yet scheduled
## Risk
- Skipping production replica test → potential data loss on edge cases not covered by staging schema
- Migration window must be off-peak; violating this risks exceeding replication lag SLA (>30s triggers alert)
## Unsurfaced Results
- Staging dry-run log: /tmp/pt-osc-staging-2026-03-19.log (not yet reviewed for warnings)
EOF
exit 0
fi
# ---------------------------------------------------------------------------
# --all-agents: detect all agent workspaces and run checks for each
# ---------------------------------------------------------------------------
OPENCLAW_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
PLUGIN_DIR_SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
detect_all_agent_workspaces() {
if [[ ! -f "$CONFIG_FILE" ]] || ! command -v python3 &>/dev/null; then
return 1
fi
python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
import json, sys, os
config_file = sys.argv[1]
openclaw_dir = sys.argv[2]
with open(config_file) as f:
data = json.load(f)
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', os.path.join(openclaw_dir, 'workspace', 'main'))
seen = set()
for agent in data.get('agents', {}).get('list', []):
agent_id = agent.get('id', '')
if not agent_id or agent_id in seen:
continue
seen.add(agent_id)
name = agent.get('name', agent_id)
workspace = agent.get('workspace', default_ws if agent_id == 'main' else os.path.join(openclaw_dir, 'workspaces', agent_id))
workspace = os.path.expanduser(workspace)
print('{}|{}|{}'.format(agent_id, name, workspace))
PYEOF
}
if $ALL_AGENTS; then
GRAY='\033[0;90m'
echo -e "\n${BOLD}memory-continuity — Multi-Agent Verifier${RESET}"
echo "────────────────────────────────────────────"
AGENT_LINES=()
while IFS= read -r line; do
AGENT_LINES+=("$line")
done < <(detect_all_agent_workspaces 2>/dev/null || true)
if [[ ${#AGENT_LINES[@]} -eq 0 ]]; then
echo -e "${RED}No agents detected. Is openclaw.json present and python3 available?${RESET}"
exit 1
fi
# Filter to alive agents (workspace directory exists)
ALIVE_LINES=()
SKIPPED=0
for line in "${AGENT_LINES[@]}"; do
IFS='|' read -r agent_id agent_name agent_ws <<< "$line"
if [[ -d "$agent_ws" ]]; then
ALIVE_LINES+=("$line")
else
echo -e " ${GRAY}Skipping ${agent_name} (${agent_id}) — workspace not found: ${agent_ws}${RESET}"
SKIPPED=$((SKIPPED + 1))
fi
done
if [[ $SKIPPED -gt 0 ]]; then
echo -e " ${GRAY}Skipped ${SKIPPED} agent(s) with missing workspace directories${RESET}"
fi
if [[ ${#ALIVE_LINES[@]} -eq 0 ]]; then
echo -e "${YELLOW}All ${#AGENT_LINES[@]} agent(s) in config have missing workspace directories.${RESET}"
echo -e "${YELLOW}You may need to initialize them first.${RESET}"
exit 1
fi
TOTAL_PASS=0
TOTAL_FAIL=0
for line in "${ALIVE_LINES[@]}"; do
IFS='|' read -r agent_id agent_name agent_ws <<< "$line"
echo -e "\n${BOLD}Agent: ${agent_name} (${agent_id})${RESET}"
echo -e " Workspace: ${agent_ws}"
AGENT_ERRORS=0
AGENT_WARNINGS=0
# Layer 1: SKILL.md in workspace
SKILL_PATH="${agent_ws}/skills/memory-continuity/SKILL.md"
if [[ -f "$SKILL_PATH" ]]; then
ok "SKILL.md installed"
else
fail "SKILL.md missing at ${SKILL_PATH}"
AGENT_ERRORS=$((AGENT_ERRORS + 1))
fi
# Layer 2: continuity_doctor.py
DOCTOR="${PLUGIN_DIR_SELF}/scripts/continuity_doctor.py"
if command -v python3 &>/dev/null && [[ -f "$DOCTOR" ]]; then
if python3 "$DOCTOR" --workspace "$agent_ws" &>/dev/null; then
ok "continuity_doctor.py passed"
else
warn "continuity_doctor.py exited non-zero"
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
fi
else
warn "python3 or continuity_doctor.py unavailable — skipped"
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
fi
# Layer 3: CURRENT_STATE.md
STATE_FILE="${agent_ws}/memory/CURRENT_STATE.md"
if [[ ! -f "$STATE_FILE" ]]; then
warn "CURRENT_STATE.md not found (normal for new installs)"
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
else
LINE_COUNT=$(grep -c '[^[:space:]]' "$STATE_FILE" || true)
if [[ $LINE_COUNT -gt 2 ]]; then
ok "CURRENT_STATE.md present (${LINE_COUNT} non-blank lines)"
else
warn "CURRENT_STATE.md appears empty or placeholder"
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
fi
fi
if [[ $AGENT_ERRORS -eq 0 ]]; then
echo -e " ${GREEN}→ PASS${RESET} (${AGENT_WARNINGS} warning(s))"
TOTAL_PASS=$((TOTAL_PASS + 1))
else
echo -e " ${RED}→ FAIL${RESET} (${AGENT_ERRORS} error(s), ${AGENT_WARNINGS} warning(s))"
TOTAL_FAIL=$((TOTAL_FAIL + 1))
fi
done
echo ""
echo "────────────────────────────────────────────"
SUMMARY_DETAIL=""
[[ $SKIPPED -gt 0 ]] && SUMMARY_DETAIL=", ${SKIPPED} skipped (no workspace)"
echo -e "${BOLD}Summary: ${TOTAL_PASS} passed, ${TOTAL_FAIL} failed (of ${#ALIVE_LINES[@]} alive agents${SUMMARY_DETAIL})${RESET}"
[[ $TOTAL_FAIL -gt 0 ]] && exit 1 || exit 0
fi
# ---------------------------------------------------------------------------
# Resolve WORKSPACE from openclaw.json if not explicitly set via --workspace
# ---------------------------------------------------------------------------
# If not explicitly set, try to derive from the first alive agent in openclaw.json
if ! $WORKSPACE_EXPLICIT && [[ -f "$CONFIG_FILE" ]] && command -v python3 &>/dev/null; then
RESOLVED_WS=$(python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
import json, sys, os
config_file = sys.argv[1]
openclaw_dir = sys.argv[2]
try:
with open(config_file) as f:
data = json.load(f)
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', '')
for agent in data.get('agents', {}).get('list', []):
ws = agent.get('workspace', default_ws)
if ws:
print(os.path.expanduser(ws))
break
except Exception:
pass
PYEOF
)
if [[ -n "$RESOLVED_WS" ]]; then
WORKSPACE="$RESOLVED_WS"
fi
fi
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
echo -e "\n${BOLD}memory-continuity — Installation Verifier${RESET}"
echo "────────────────────────────────────────────"
ERRORS=0
WARNINGS=0
# ===========================================================================
# Layer 1: Required files exist
# ===========================================================================
header "Layer 1: Required files"
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
check_file() {
local rel="$1"
local path="${PLUGIN_DIR}/${rel}"
if [[ -f "$path" ]]; then
ok "$rel"
else
fail "$rel — NOT FOUND (expected at ${path})"
ERRORS=$((ERRORS + 1))
fi
}
check_file "SKILL.md"
check_file "scripts/continuity_doctor.py"
check_file "openclaw.plugin.json"
# ===========================================================================
# Layer 2: continuity_doctor.py runs without error
# ===========================================================================
header "Layer 2: Tool availability"
if ! command -v python3 &>/dev/null; then
fail "python3 not found in PATH"
ERRORS=$((ERRORS + 1))
else
ok "python3 found ($(python3 --version 2>&1))"
DOCTOR="${PLUGIN_DIR}/scripts/continuity_doctor.py"
if [[ ! -f "$DOCTOR" ]]; then
fail "continuity_doctor.py missing — skipping doctor check"
ERRORS=$((ERRORS + 1))
else
info "Running: python3 scripts/continuity_doctor.py --workspace ${WORKSPACE}"
echo ""
if python3 "$DOCTOR" --workspace "$WORKSPACE" 2>&1 | sed 's/^/ /'; then
echo ""
ok "continuity_doctor.py exited cleanly"
else
echo ""
warn "continuity_doctor.py exited with non-zero status (see output above)"
WARNINGS=$((WARNINGS + 1))
fi
fi
fi
# ===========================================================================
# Layer 3: CURRENT_STATE.md content check
# ===========================================================================
header "Layer 3: Workspace state"
STATE_FILE="${WORKSPACE}/memory/CURRENT_STATE.md"
if [[ ! -f "$STATE_FILE" ]]; then
warn "CURRENT_STATE.md not found at: ${STATE_FILE}"
warn "This is normal for a brand-new install — state will be created after your first session."
WARNINGS=$((WARNINGS + 1))
else
# Detect placeholder / trivial content:
# - File is empty
# - Only contains the header line "# Current State"
# - Contains the phrase "No active" or "placeholder"
CONTENT=$(cat "$STATE_FILE")
LINE_COUNT=$(echo "$CONTENT" | grep -c '[^[:space:]]' || true)
IS_PLACEHOLDER=false
if [[ $LINE_COUNT -le 2 ]]; then
IS_PLACEHOLDER=true
elif ! echo "$CONTENT" | grep -qE '##\s+(Objective|Current Step|In Flight|Next Action|Blocked)'; then
# Has some lines but none of the expected structured sections
IS_PLACEHOLDER=true
else
# Check if the Objective section itself is trivial (placeholder text right after the heading)
OBJECTIVE_VALUE=$(echo "$CONTENT" | awk '/^## Objective/{found=1; next} found && /^##/{exit} found && /[^[:space:]]/{print; exit}')
if echo "$OBJECTIVE_VALUE" | grep -qiE '^\s*(no active|placeholder|todo|tbd|empty|n\/a|none|untitled)$'; then
IS_PLACEHOLDER=true
fi
fi
if $IS_PLACEHOLDER; then
echo ""
warn "CURRENT_STATE.md exists but appears to be empty or a placeholder."
warn "The plugin is installed correctly, but your working state hasn't been"
warn "captured yet. After your first real work session it will be populated"
warn "automatically."
echo ""
warn "To see what a high-importance state entry looks like, run:"
echo -e " ${CYAN}bash scripts/verify.sh --sample${RESET}"
WARNINGS=$((WARNINGS + 1))
else
ok "CURRENT_STATE.md found with substantive content (${LINE_COUNT} non-blank lines)"
# Show last-updated line if present
UPDATED=$(grep -m1 'Last updated' "$STATE_FILE" || true)
[[ -n "$UPDATED" ]] && info "$UPDATED"
fi
fi
# ===========================================================================
# Summary
# ===========================================================================
echo ""
echo "────────────────────────────────────────────"
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
echo -e "${GREEN}${BOLD}All checks passed. memory-continuity is correctly installed.${RESET}"
elif [[ $ERRORS -eq 0 ]]; then
echo -e "${YELLOW}${BOLD}Checks passed with ${WARNINGS} warning(s). Review warnings above.${RESET}"
else
echo -e "${RED}${BOLD}${ERRORS} error(s), ${WARNINGS} warning(s). Installation may be incomplete.${RESET}"
echo -e "${RED}Re-run the installer: bash scripts/post-install.sh${RESET}"
fi
echo ""