52 Commits
Author SHA1 Message Date
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
taodengandClaude Opus 4.6 ddc472b63f chore: bump version to 2.2.0
v2.2.0 — first fully working plugin release:
- Fix: resolve workspace dir from hook context (_ctx), not api.runtime
- Fix: strip channel metadata from user messages before state extraction
- Both fixes are required for gateway/Telegram/Discord deployments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:05:56 +10:00
taodengandClaude Opus 4.6 53c75986fa fix: use _ctx.workspaceDir and strip channel metadata
- Hook callbacks now get workspace from _ctx.workspaceDir (the hook
  context parameter) instead of api.runtime.workspaceDir which is
  undefined in gateway mode. This was the root cause of the plugin
  silently doing nothing on all remote/gateway deployments.

- extractStateFromMessages now strips Telegram/Discord channel
  metadata ("Conversation info (untrusted metadata)") from user
  messages before saving to CURRENT_STATE.md, preventing garbage
  data in the Objective field.

Tested on: macOS local (GPT-5.4), Oracle Cloud (MiniMax M2.5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:44:12 +10:00
taodengandClaude Opus 4.6 86d820af81 fix: add install record for provenance tracking
post-install.sh now writes plugins.installs with source:"path" so
OpenClaw recognizes the plugin origin. Eliminates both the allow-list
and provenance warnings on fresh install. Script is now fully
idempotent — always refreshes allow/installs/entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:59:26 +10:00
taodengandClaude Opus 4.6 7d7e0e7bdb v2.1.0: fix provenance warnings, clean install experience
Add package.json with openclaw plugin metadata, configure plugins.allow
in post-install.sh, and add source field to plugin manifest. Fresh
installs now load without trust/provenance warnings in gateway logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:51:19 +10:00
taodengandClaude Opus 4.6 61f2d34366 docs: add "Why this plugin?" section highlighting differentiators
Emphasize zero-dependency, plain-file, backup/migrate simplicity,
upgrade-proof design vs vector-DB-based memory plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:18:06 +10:00
taodengandClaude Opus 4.6 7088d3d7d0 docs: update changelog and GitHub description for v2.0.0
Unify version references across all files. Update GitHub repo
description from "Draft skill" to "Lifecycle plugin".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:13:50 +10:00
taodengandClaude Opus 4.6 9ab2667aa3 feat: upgrade from skill to lifecycle plugin (v2.0.0)
Replace prompt-based skill approach with OpenClaw lifecycle hooks
(before_agent_start, agent_end, before_compaction, before_reset).
State injection now happens automatically at the hook level,
making it model-agnostic and reliable across all providers.

- Add index.js with 5 lifecycle hooks
- Add openclaw.plugin.json manifest
- Rewrite post-install.sh to handle plugin installation
- Update README with plugin architecture docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:11:14 +10:00
taodengandClaude Opus 4.6 38c1fe875d feat: add post-install script and troubleshooting guide
Add scripts/post-install.sh to clear stale skillsSnapshot cache from
existing sessions, ensuring the skill loads immediately after install.
Update README with corrected install path and post-install instructions.
Include detailed troubleshooting report documenting the snapshot cache
root cause analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:36:12 +10:00
taodeng dcc9093182 docs: clarify discord continuity is unsupported in alpha 2026-03-13 10:05:32 +10:00
16 changed files with 4390 additions and 139 deletions
+100 -18
View File
@@ -1,28 +1,110 @@
# Changelog
## v0.3.0-probe — 2026-03-13
## v3.0.0 — 2026-03-24
### Summary
This release marks the transition from a skill-only continuity package to a
**dual-form package**:
- the existing `SKILL.md` remains the fallback behavior contract
- a new **lifecycle plugin probe** is included to validate the primary runtime path
Major release: **interactive `/mc` commands** for Telegram, Discord, and CLI. You can now inspect, search, restore, and manage agent memory directly from chat.
### Added
- `plugin/lifecycle-prototype.ts`
- `references/phase2-hook-validation.md`
- `references/scope.md`
- `references/plugin-design.md`
- **`/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
- repository direction clarified: primary long-term path is now a standard lifecycle plugin
- ContextEngine is now documented as a future option, not the v1 default
- README updated to describe the package as skill + lifecycle plugin probe
- skill docs aligned to the lifecycle-plugin plan
- Bumped version to 3.0.0
- Plugin now ships as two components: `memory-continuity` (lifecycle hooks) + `mc` (slash commands)
### Validated
- Experiment A passed on multiple resident subagents (`tech_geek`, `travel_assistant` after workspace/startup-rule cleanup)
- startup continuity injection can work without `read`
---
### Pending
- Experiment C (compaction-path verification) remains pending because no real compaction event was triggered in the earlier pressure test
## 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
### Added
- `openclaw.plugin.json` manifest with `configSchema`
- `scripts/post-install.sh` one-command installer
### Changed
- README updated with "Why this plugin?" section
---
## v2.0.0 — 2026-03-15
### Summary
Architecture upgrade: skill (prompt-based) → **plugin** (lifecycle hooks).
### Added
- `index.js` with 5 lifecycle hooks: `before_agent_start`, `before_compaction`, `before_reset`, `agent_end`, `session_end`
- Automated state injection via `prependSystemContext`
- Auto-extraction of working state from conversation
### Removed
- Dependency on model cooperation for state read/write
---
## v1.0.0 — 2026-03-14
### Summary
Initial release as a skill (SKILL.md only). Required model to voluntarily read/write `CURRENT_STATE.md`. Did not work reliably with weaker models.
+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.
+263 -118
View File
@@ -1,92 +1,223 @@
# memory-continuity
**Current release:** `v0.3.0-probe`
**Current release:** `v4.0.0`
OpenClaw continuity package for **short-term working continuity** — currently shipped as:
- a **skill** (`SKILL.md`) for behavior contract / fallback recovery
- a **lifecycle plugin probe** (`plugin/lifecycle-prototype.ts`) for validating the primary runtime path
OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
Its goal is to let an agent recover structured in-flight work state after `/new`, reset, gateway interruption, model fallback, or compaction.
## Why this plugin?
There are feature-rich memory plugins out there (vector search, semantic dedup, smart extraction). We took a different path:
- **Zero dependency** — no embedding API, no vector DB, no external services
- **Plain files** — data is markdown, human-readable, editable, greppable
- **Hook-driven** — doesn't rely on model behavior, works with any model
- **Backup = copy** — `cp` / `scp` / `rsync` is your entire backup strategy
- **Migrate in seconds** — copy files to new host, done. No re-indexing, no model binding
- **Upgrade-proof** — doesn't occupy the `contextEngine` slot, doesn't depend on OpenClaw internals
- **Native-consistent** — aligns with OpenClaw's `memory/` file conventions
If what you need is "don't lose work across sessions" rather than "semantic search over 100k memories", this plugin is for you.
## What problem does this solve?
OpenClaw already preserves a lot:
- transcripts
- compaction summaries
- memory files
- session memory search
But those do not always answer the most operational question:
OpenClaw already preserves transcripts, compaction summaries, memory files, and session memory search. But those don't always answer the most operational question:
> What were we doing right now, where did we stop, and what should happen next?
That is the problem this skill solves.
That is the problem this plugin solves.
**One-line summary:**
- long-term memory = what you know
- memory continuity = what you are doing right now
## Current architecture stance
## How it works
This repository should now be understood as a **continuity package**, not just a standalone skill.
The plugin uses OpenClaw lifecycle hooks to **automatically** save and restore working state — no model cooperation needed.
### Included forms
- **Skill** = behavior contract / fallback implementation / human-readable protocol
- **Lifecycle plugin probe** = current runtime experiment for the primary architecture
| Hook | What it does |
|---|---|
| `before_agent_start` | Reads `memory/CURRENT_STATE.md` and injects it into the agent's system context |
| `before_compaction` | Injects state before compaction so it survives context compression |
| `before_reset` | Archives current state to `session_archive/` before `/new` |
| `agent_end` | Auto-extracts working state from conversation if no explicit state exists |
| `session_end` | Ensures `CURRENT_STATE.md` exists for future sessions |
The intended primary runtime path is a **standard lifecycle plugin** that can
improve startup, `/new`, and compaction continuity **without consuming
OpenClaws exclusive `contextEngine` slot**.
A ContextEngine implementation remains a **future option**, not the default
v1 direction.
Because state injection happens at the hook level (before the model sees anything), it works with **any model** — GPT-4o, MiniMax, Claude, etc.
## 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
```bash
cd ~/.openclaw/workspace/skills/
# Clone the plugin
git clone https://github.com/dtzp555-max/memory-continuity.git
# Run the installer
cd memory-continuity
bash scripts/post-install.sh
```
The installer will:
1. Copy the plugin to `~/.openclaw/extensions/memory-continuity/`
2. Add the plugin entry to `~/.openclaw/openclaw.json`
3. Add `memory-continuity` to `plugins.allow` (trust list)
4. Add install record to `plugins.installs` (provenance tracking)
5. Restart the gateway
No npm install, no API keys, no external database.
### Test the current skill version
1. Start a multi-step task with your agent
2. Make a few concrete decisions
3. Check whether `memory/CURRENT_STATE.md` exists and reflects the work state
4. Trigger `/new`
5. Ask a recovery question like:
- “刚才我们说到哪了”
- “continue”
- “what were we doing”
A good recovery should surface the current objective / step / next action,
not generic small talk.
### Run the doctor
### Verify
```bash
python3 scripts/continuity_doctor.py --workspace ~/.openclaw/workspace
# 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
```
## How the current skill version works
```bash
# Alternatively, confirm the gateway loaded the plugin
openclaw gateway restart 2>&1 | grep memory-continuity
# Should show: [memory-continuity] Plugin registered successfully
```
The skill defines a discipline around one file:
- `memory/CURRENT_STATE.md`
### Test
That file is the short-term workbench for active work. It is:
- overwritten, not appended
- intentionally short
- structured for fast recovery
1. Tell your agent something memorable (e.g., "I'll tell you a secret: Ethan is super kid")
2. Send `/new` to reset the session
3. Ask "what was the secret?" or "我们刚才聊到哪了"
4. The agent should immediately surface the recovered state
### The checkpoint shape
## 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
The plugin works with zero configuration. Optional settings in `openclaw.json`:
```json
{
"plugins": {
"allow": ["memory-continuity"],
"entries": {
"memory-continuity": {
"enabled": true,
"hooks": {
"allowPromptInjection": true
},
"config": {
"maxStateLines": 50,
"archiveOnNew": true,
"autoExtract": true
}
}
}
}
}
```
| Option | Default | Description |
|---|---|---|
| `maxStateLines` | `50` | Max lines for CURRENT_STATE.md |
| `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` |
| `autoExtract` | `true` | Auto-extract state from conversation at session end |
| `maxArchiveCount` | `20` | Maximum archive files to keep (oldest auto-deleted) |
## The checkpoint file
The plugin maintains one file: `$WORKSPACE/memory/CURRENT_STATE.md`
```markdown
# Current State
> Last updated: 2026-03-12T14:30:00Z
> Last updated: 2026-03-15T14:00:00Z
## Objective
Build the user authentication module
@@ -96,7 +227,6 @@ Completed JWT token generation, starting refresh endpoint
## Key Decisions
- Using RS256 for token signing (user approved)
- Token expiry: 15 minutes access, 7 days refresh
## Next Action
Implement POST /auth/refresh endpoint
@@ -108,100 +238,115 @@ None
None
```
## Recovery rules
This file is:
- **Overwritten**, not appended (it's a checkpoint, not a journal)
- **Human-readable** plain markdown
- **Portable** — just copy the file to backup or migrate
- **Model-agnostic** — injected via hooks, not dependent on model behavior
In recovery scenarios, the skill expects the agent to prioritize:
- Objective
- Current Step
- Next Action
- Blockers
- Unsurfaced Results
## Backup & Migration
A generic greeting should **not** outrank recovery state when the checkpoint
contains active work.
```bash
# Backup
cp $WORKSPACE/memory/CURRENT_STATE.md /backup/
## Relationship to native OpenClaw features
# Migrate to another machine
scp -r ~/.openclaw/extensions/memory-continuity/ newhost:~/.openclaw/extensions/
scp $WORKSPACE/memory/CURRENT_STATE.md newhost:$WORKSPACE/memory/
```
### Native OpenClaw already handles
- transcript persistence
- compaction
- pre-compaction `memoryFlush`
- session memory search
- system prompt/bootstrap assembly
No database, no vector embeddings, no API keys to transfer.
### memory-continuity adds
- a **structured working-state checkpoint**
- explicit short-term recovery fields
- a deterministic place to look for active work state
- explicit handling for `Unsurfaced Results`
## Recovery after OpenClaw upgrade
### Important boundary
Session memory search is useful for:
- “what did we discuss before?”
- “what decision was mentioned in a prior session?”
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:
Memory continuity is for:
- “what are we doing right now?”
- “where did we stop?”
- “what should happen next?”
### 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
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.
v2.0 is a **lifecycle plugin** that uses OpenClaw hooks. The key difference:
| | Skill (v0.x) | Plugin (v2.0) |
|---|---|---|
| State injection | Model must read the file | Hook injects automatically |
| State saving | Model must write the file | Hook saves automatically |
| Model dependency | Requires model cooperation | Model-agnostic |
| Reliability | Varies by model | Consistent |
The skill (`SKILL.md`) is retained as documentation and fallback protocol.
## Repository layout
```text
memory-continuity/
├── SKILL.md
├── openclaw.plugin.json # Plugin manifest
├── index.js # Plugin entry point (hooks)
├── SKILL.md # Behavior contract / protocol docs
├── README.md
├── LICENSE
├── plugin/
│ └── lifecycle-prototype.ts # Phase 2 probe / not production yet
│ └── lifecycle-prototype.ts # Original prototype (reference)
├── references/
│ ├── template.md
│ ├── doctor-spec.md
│ └── phase2-hook-validation.md
└── scripts/
── continuity_doctor.py
```
At runtime, the skill works primarily with:
```text
$WORKSPACE/
└── memory/
├── CURRENT_STATE.md
└── session_archive/
── post-install.sh # Automated installer
├── verify.sh # 3-layer install verifier
└── continuity_doctor.py # Health check
```
## Design principles
1. **Files are the source of truth**
2. **Structured checkpoint beats free-form recollection**
3. **Recovery must prefer truth over confident guessing**
4. **This complements native OpenClaw memory; it does not replace it**
5. **Read access is helpful, but should not be the only long-term path**
6. **The primary plugin direction should coexist with other ecosystem plugins such as `lossless-claw`**
1. **Files are the source of truth** — plain markdown, no database
2. **Hooks over prompts** — don't rely on model behavior
3. **Zero external dependencies** — no API keys, no vector DB
4. **Portable and backupable**`cp` is your backup tool
5. **Complements native OpenClaw memory** — does not replace it
## Current roadmap
## Support
### Phase 1
Strengthen the current skill version:
- tighten recovery behavior
- tighten checkpoint discipline
- improve doctor and docs
If you find this plugin useful, please consider giving it a ⭐ on GitHub — it helps others discover the project!
### Phase 2
Build and validate a **standard lifecycle plugin** as the primary runtime path:
- startup recovery behavior
- `/new` checkpointing
- compaction-boundary checkpointing
- end-of-run safety writes
- hook validation in real resident subagent sessions
### Future option
Evaluate a ContextEngine variant later only if the slot tradeoff is justified.
## Release notes
See `CHANGELOG.md` for the current packaged milestone history.
[![GitHub stars](https://img.shields.io/github/stars/dtzp555-max/memory-continuity?style=social)](https://github.com/dtzp555-max/memory-continuity)
## License
+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 |
| 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
`CURRENT_STATE.md` should usually stay under about 40 lines and be readable in
+173
View File
@@ -0,0 +1,173 @@
# memory-continuity Skill 排错报告
> 日期: 2026-03-14
> 排错人: Claude Opus 4.6 (via Claude Code)
> 环境: OpenClaw 2026.3.12 (6472949) / macOS / Node 25.8.0
---
## 问题描述
memory-continuity skill 安装后无法正常工作。具体表现:
1. `/new` 重置会话后,agent 不按照 skill 定义的恢复优先级协议行事
2. Agent 说"我不记得",然后才提到 `CURRENT_STATE.md` 中的"残留记录"
3. `openclaw skills check` 显示 skill 状态为 `✓ ready`,但 agent 的 system prompt 中没有加载它
---
## 排错过程
### 第一阶段:确认 skill 文件完整性
- 检查 `~/.openclaw/workspace/main/skills/memory-continuity/SKILL.md` — 存在且 frontmatter 格式正确
- 检查 `openclaw.json` — 5 个 agent 的 `skills` 数组中均已包含 `"memory-continuity"`
- 检查 `openclaw skills check` — 显示 readysource 为 `openclaw-workspace`
- **结论:安装和配置层面无问题**
### 第二阶段:验证 skill 是否进入 agent prompt
通过 `openclaw agent --agent main -m "ping" --json` 获取 `systemPromptReport`,发现:
- 加载了 20 个 skills**memory-continuity 不在其中**
- 对比发现 `secureclaw`(同为 workspace skill)成功加载
对比两者差异:
| 项目 | secureclaw | memory-continuity |
|---|---|---|
| SKILL.md | ✓ | ✓ |
| skill.json | ✓ | ✗ |
| _meta.json | ✓ | ✗ |
尝试为 memory-continuity 补充 `skill.json``_meta.json` 后重启 gateway**问题未解决**。
### 第三阶段:逆向分析 skill loader 源码
反编译分析 OpenClaw 的 skill 加载链路:
```
resolveSkillsPromptForRun()
→ 优先使用 skillsSnapshotsession 缓存)
→ 否则调用 buildWorkspaceSkillSnapshot()
→ resolveWorkspaceSkillPromptState()
→ filterSkillEntries()
→ shouldIncludeSkill() // 过滤
→ skillFilter // allowlist
```
关键发现:
1. **skill 发现机制**`loadSkillEntries`)基于文件系统扫描 `workspace/skills/*/SKILL.md`,与 `_meta.json``skill.json` 无关
2. **skill 过滤机制**`filterSkillEntries`)使用 agent 配置中的 `skills` 数组作为 allowlist
3. **skill snapshot 缓存**`skillsSnapshot`)存储在 session store 中,只在以下条件刷新:
- `isFirstTurnInSession`(首轮对话)
- `snapshotVersion > 0` 且版本号增加
### 第四阶段:定位根因
检查 session store 中的 skills snapshot
```json
// ~/.openclaw/agents/main/sessions/sessions.json
// session "agent:main:main"
{
"skillsSnapshot": {
"version": 0,
"skills": [/* 20 skill memory-continuity */]
}
}
```
**根因确认:**
- 所有 239 个 session 在 memory-continuity 安装前就已缓存了 skills snapshot
- snapshot `version: 0`,而刷新条件是 `snapshotVersion > 0`,导致永远不会自动刷新
- 后续对话复用已有 session(非 firstTurn),跳过重建
- 结果:无论怎么重启 gateway 或重装 skill,缓存的旧 snapshot 始终被使用
---
## 修复措施
### 1. 清除所有 session 的过期 skillsSnapshot(关键修复)
```python
# 遍历 sessions.json,删除所有 session 的 skillsSnapshot 字段
for key in data:
if 'skillsSnapshot' in data[key]:
del data[key]['skillsSnapshot']
# 共清除 239 个 session 的缓存
```
下次 agent 响应时,检测到 `!current.skillsSnapshot`,触发 `buildWorkspaceSkillSnapshot()` 重建,新 snapshot 包含 memory-continuity。
### 2. 补充 skill.json(规范性改进)
创建 `workspace/main/skills/memory-continuity/skill.json`
```json
{
"name": "memory-continuity",
"version": "1.0.0",
"description": "Short-term working continuity for OpenClaw agents...",
"author": "dtzp555-max",
"license": "MIT",
"homepage": "https://github.com/dtzp555-max/memory-continuity"
}
```
### 3. 补充 _meta.json(规范性改进)
创建 `workspace/main/skills/memory-continuity/_meta.json`
```json
{
"ownerId": "github:dtzp555-max",
"slug": "memory-continuity",
"version": "1.0.0",
"publishedAt": 1710388800000
}
```
> 注:措施 2 和 3 对 skill 加载无实际影响,但与其他 workspace skill(如 secureclaw)保持一致。
---
## 验证结果
### Skill 加载验证
```
修复前: Total: 20, memory-continuity: False
修复后: Total: 11, memory-continuity: True (620 chars)
```
加载的 11 个 skills 与 main agent 配置的 `skills` 数组完全匹配。
### 功能黑盒测试
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 告诉 agent 秘密信息 | agent 确认记录 |
| 2 | 检查 CURRENT_STATE.md | 秘密已写入 `## In Flight` |
| 3 | 删除 session 模拟 `/new` | 新 session 创建 |
| 4 | 在新 session 中问秘密 | agent 从 CURRENT_STATE.md 恢复,正确回答 |
---
## 经验总结
1. **OpenClaw 的 skill 加载不是实时的** — session store 中的 `skillsSnapshot` 会缓存 skill 列表,新安装的 skill 不会自动出现在已有 session 中
2. **`openclaw skills check` 显示 ready 不代表已加载** — ready 只表示文件系统发现成功,实际加载还受 session snapshot 缓存影响
3. **snapshot version = 0 是一个 edge case** — 在这个版本下,自动刷新逻辑永远不会触发(`snapshotVersion > 0` 为 false
4. **重启 gateway 不会清除 session snapshot** — snapshot 持久化在 sessions.json 中,只有清除缓存或触发 firstTurn 才能刷新
---
## 涉及文件
| 文件 | 操作 |
|---|---|
| `~/.openclaw/agents/main/sessions/sessions.json` | 清除 239 个 session 的 skillsSnapshot |
| `~/.openclaw/workspace/main/skills/memory-continuity/skill.json` | 新建 |
| `~/.openclaw/workspace/main/skills/memory-continuity/_meta.json` | 新建 |
@@ -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"`.
+962
View File
@@ -0,0 +1,962 @@
import fs from "node:fs";
import path from "node:path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAX_ARCHIVE_COUNT = 20;
const MAX_MEMORY_FILES = 500;
const PLACEHOLDER_VALUES = new Set([
"", "none", "n/a", "na", "idle",
"[one sentence: what are we trying to accomplish]",
"[exactly what should happen next]",
]);
// Matches a single CJK character (no `g` flag — used per-char in estimateTokens)
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/;
// Patterns that indicate error/garbage assistant responses — kept at module scope
// so the array is not re-created on every extractStateFromMessages call.
const POISON_PATTERNS = [
/not logged in/i,
/please run \/login/i,
/unknown skill/i,
/session expired/i,
/auth.*failed/i,
/error.*timeout/i,
];
const isPoisoned = (text) => POISON_PATTERNS.some(p => p.test(text));
/**
* Estimate token count with CJK awareness.
* CJK characters ≈ 1.5 tokens each; Latin/other chars ≈ 1 token per ~4 chars.
* Uses a single-pass for...of loop so surrogate pairs are iterated by code point.
*/
function estimateTokens(text) {
if (!text) return 0;
let cjkCount = 0;
let nonCjkLen = 0;
for (const ch of text) {
if (CJK_RE.test(ch)) cjkCount++;
else nonCjkLen++;
}
return Math.ceil(cjkCount * 1.5) + Math.ceil(nonCjkLen / 4);
}
const STATE_TEMPLATE = `# Current State
> Last updated: ${new Date().toISOString()}
## Objective
None
## Current Step
None
## Key Decisions
- None
## Next Action
None
## Blockers
None
## Unsurfaced Results
None
`;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveStatePath(workspaceDir) {
if (!workspaceDir) return null;
return path.join(workspaceDir, "memory", "CURRENT_STATE.md");
}
function readFile(filePath) {
try { return fs.readFileSync(filePath, "utf8"); } catch { return null; }
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
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 isMeaningful(value) {
return !PLACEHOLDER_VALUES.has(value.trim().toLowerCase());
}
function buildSnapshot(md) {
const objective = extractSection(md, "Objective");
if (!isMeaningful(objective)) return null;
const fields = {
"Objective": objective,
"Current Step": extractSection(md, "Current Step") || "unknown",
"Key Decisions": extractSection(md, "Key Decisions") || "None",
"Next Action": extractSection(md, "Next Action") || "unknown",
"Blockers": extractSection(md, "Blockers") || "None",
"Unsurfaced Results": extractSection(md, "Unsurfaced Results") || "None",
};
const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
return [
"=== CONTINUITY RECOVERY ===",
...Object.entries(fields).map(([k, v]) => `${k}: ${v}`),
`Last Updated: ${updated}`,
"=== END RECOVERY ===",
].join("\n");
}
function archiveState(workspaceDir, md, config = {}) {
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 archiveDir = path.join(workspaceDir, "memory", "session_archive");
fs.mkdirSync(archiveDir, { recursive: true });
fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), md, "utf8");
// Enforce archive count limit
const maxCount = config.maxArchiveCount || MAX_ARCHIVE_COUNT;
const files = fs.readdirSync(archiveDir).sort();
if (files.length > maxCount) {
const toDelete = files.slice(0, files.length - maxCount);
for (const f of toDelete) {
try { fs.unlinkSync(path.join(archiveDir, f)); } catch {}
}
}
// Clean up memory directory
cleanupMemoryDir(workspaceDir);
}
function cleanupMemoryDir(workspaceDir) {
const memoryDir = path.join(workspaceDir, "memory");
try {
const entries = fs.readdirSync(memoryDir);
// Clean up legacy STATE_ARCHIVE_*.md files from memory/ root
const legacyArchives = entries.filter(f => /^STATE_ARCHIVE_.*\.md$/.test(f));
for (const f of legacyArchives) {
try { fs.unlinkSync(path.join(memoryDir, f)); } catch {}
}
// Check total file count (top level only)
const remaining = fs.readdirSync(memoryDir);
const fileEntries = remaining.filter(f => {
try { return fs.statSync(path.join(memoryDir, f)).isFile(); } catch { return false; }
});
if (fileEntries.length > MAX_MEMORY_FILES) {
const PROTECTED = new Set(["CURRENT_STATE.md", "MEMORY.md", "INDEX.md"]);
const deletable = fileEntries
.filter(f => !PROTECTED.has(f) && f.endsWith(".md"))
.map(f => ({ name: f, mtime: fs.statSync(path.join(memoryDir, f)).mtimeMs }))
.sort((a, b) => a.mtime - b.mtime);
const toRemove = deletable.slice(0, fileEntries.length - 450);
for (const { name } of toRemove) {
try { fs.unlinkSync(path.join(memoryDir, name)); } catch {}
}
}
} catch {}
}
/**
* 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 contentToString = (m) => {
if (typeof m?.content === "string") return m.content;
if (Array.isArray(m?.content))
return m.content.filter(b => b?.type === "text").map(b => b.text).join(" ");
return "";
};
const totalTokens = estimateTokens(messages.map(contentToString).join(" "));
// Build log entry
const entry = [
`### ${timeStr}`,
`- **Topic:** ${topic}`,
`- **Messages:** ${userCount} user / ${assistantCount} assistant`,
`- **Est. tokens:** ~${totalTokens}`,
"",
].join("\n");
// Append-safe: use appendFileSync for both new and existing files
const isNew = !fs.existsSync(logFile);
const prefix = isNew ? `# Session Log — ${dateStr}\n\n` : "";
fs.appendFileSync(logFile, prefix + entry, "utf8");
// Extract and index #tags from the topic
updateTagIndex(workspaceDir, topic, dateStr);
}
/**
* 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;
}
// Avoid splitting surrogate pairs
while (lo > 0 && lo < s.length && s.charCodeAt(lo) >= 0xDC00 && s.charCodeAt(lo) <= 0xDFFF) lo--;
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");
}
/**
* 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");
}
/**
* 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");
}
/**
* 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(/#[\p{L}\p{N}_-]+/gu);
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);
}
/**
* Find relevant historical entries by keyword matching against the current objective.
* Searches session logs and daily summaries, returns up to `maxItems` formatted entries.
*/
function findRelevantHistory(workspaceDir, objective, maxItems = 3) {
if (!objective || objective.length < 10) return null;
// Extract keywords from objective (words > 3 chars, excluding common words)
const stopWords = new Set(["this", "that", "with", "from", "have", "been", "will", "what", "when", "where", "which", "there", "their", "about", "would", "should", "could", "into", "some", "them", "than", "then", "these", "those", "just", "also", "more", "other", "after", "before", "none"]);
const words = objective
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf-]/g, " ")
.split(/\s+/)
.filter(w => w.length > 3 && !stopWords.has(w.toLowerCase()))
.map(w => w.toLowerCase());
if (words.length === 0) return null;
// Build a scoring regex from keywords
const keywordPatterns = words.slice(0, 10).map(w => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const scored = [];
// Search daily summaries (most recent 14)
const dailyDir = path.join(workspaceDir, "memory", "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 kw of keywordPatterns) {
const matches = lower.match(new RegExp(kw, "gi"));
if (matches) score += matches.length;
}
if (score > 0) {
const date = f.replace(".md", "");
// Extract topics section for context
const topicsMatch = content.match(/## Topics\n([\s\S]*?)(?:\n##|$)/);
const topics = topicsMatch ? topicsMatch[1].trim().split("\n").slice(0, 3).join("; ") : "";
scored.push({ date, type: "daily", score, summary: topics || content.split("\n").slice(0, 3).join(" ") });
}
}
} catch {}
// Search session logs (most recent 7 days)
const sessionsDir = path.join(workspaceDir, "memory", "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 7)) {
const content = readFile(path.join(sessionsDir, f));
if (!content) continue;
// Score individual session entries
const entries = content.split(/^### /gm).filter(e => e.trim());
for (const entry of entries) {
let score = 0;
const lower = entry.toLowerCase();
for (const kw of keywordPatterns) {
const matches = lower.match(new RegExp(kw, "gi"));
if (matches) score += matches.length;
}
if (score > 1) { // Require at least 2 keyword hits for session entries
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const time = entry.match(/^(\d{2}:\d{2})/)?.[1] || "";
const date = f.replace(".md", "");
scored.push({ date: `${date} ${time}`, type: "session", score, summary: topicMatch?.[1] || entry.slice(0, 80) });
}
}
}
} catch {}
if (scored.length === 0) return null;
// Sort by score descending, take top N
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, maxItems);
const lines = ["=== RELATED HISTORY ==="];
for (const item of top) {
lines.push(`[${item.date}] ${item.summary}`);
}
lines.push("=== END RELATED HISTORY ===");
return lines.join("\n");
}
/**
* Move archives older than `decayDays` to a cold/ subdirectory.
* Files are preserved, not deleted — they just leave the active index.
*/
function decayOldArchives(workspaceDir, config = {}) {
const decayDays = config.archiveDecayDays ?? 30;
if (decayDays <= 0) return; // Disabled
const archiveDir = path.join(workspaceDir, "memory", "session_archive");
const coldDir = path.join(archiveDir, "cold");
let files;
try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")); }
catch { return; }
const cutoff = Date.now() - (decayDays * 86400000);
let movedCount = 0;
for (const f of files) {
// Parse date from filename: YYYY-MM-DD_HH-MM.md
const dateMatch = f.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!dateMatch) continue;
const fileDate = new Date(
parseInt(dateMatch[1]),
parseInt(dateMatch[2]) - 1,
parseInt(dateMatch[3])
).getTime();
if (fileDate < cutoff) {
fs.mkdirSync(coldDir, { recursive: true });
const src = path.join(archiveDir, f);
const dst = path.join(coldDir, f);
try {
// Move: copy then delete
fs.copyFileSync(src, dst);
fs.unlinkSync(src);
movedCount++;
} catch {}
}
}
return movedCount;
}
function extractStateFromMessages(messages) {
if (!messages || messages.length === 0) return null;
// Walk messages backwards to find the last meaningful exchange
const userMessages = [];
const assistantMessages = [];
for (const msg of messages) {
const role = msg?.role;
const content = typeof msg?.content === "string"
? msg.content
: Array.isArray(msg?.content)
? msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n")
: null;
if (!content) continue;
// Strip channel metadata (Telegram, Discord, etc.) from user messages
const cleaned = role === "user"
? content
.replace(/^Conversation info \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim()
: content;
if (role === "user" && cleaned) userMessages.push(cleaned);
if (role === "assistant") assistantMessages.push(content);
}
if (userMessages.length === 0 && assistantMessages.length === 0) return null;
// Build a simple state from the conversation tail
const lastUser = userMessages[userMessages.length - 1] || "";
const lastAssistant = assistantMessages[assistantMessages.length - 1] || "";
// 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;
}
// Avoid splitting surrogate pairs
while (lo > 0 && lo < s.length && s.charCodeAt(lo) >= 0xDC00 && s.charCodeAt(lo) <= 0xDFFF) lo--;
return s.slice(0, lo) + "...";
};
// Filter out error/garbage responses that would poison future sessions
if (isPoisoned(lastAssistant)) return null;
if (isPoisoned(lastUser) && !lastAssistant) return null;
return `# Current State
> Last updated: ${new Date().toISOString()}
## Objective
${truncate(lastUser, 300)}
## Current Step
Conversation ended after ${messages.length} messages
## Key Decisions
- Auto-extracted from conversation
## Next Action
Continue from where we left off
## Blockers
None
## Unsurfaced Results
${truncate(lastAssistant, 500)}
`;
}
// ---------------------------------------------------------------------------
// Plugin Definition
// ---------------------------------------------------------------------------
const plugin = {
id: "memory-continuity",
name: "Memory Continuity",
register(api) {
const log = api.logger || console;
const getConfig = () => api.pluginConfig || {};
// ------------------------------------------------------------------
// HOOK 1: before_agent_start — inject recovered state into context
// ------------------------------------------------------------------
api.on("before_agent_start", 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 recovered state into context");
const parts = [snapshot];
// 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");
}
}
return {
prependSystemContext:
parts.join("\n\n") + "\n\n" +
"IMPORTANT: The above is recovered working state from a previous session. " +
"If the user appears to be resuming work, surface this state immediately " +
"before any generic greeting. This is a continuity requirement, not optional.",
};
}, { priority: 10 });
// ------------------------------------------------------------------
// HOOK 2: before_compaction — inject state so it survives compaction
// ------------------------------------------------------------------
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 });
// ------------------------------------------------------------------
// HOOK 3: before_reset (/new) — archive current state
// ------------------------------------------------------------------
api.on("before_reset", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
if (!ws || config.archiveOnNew === false) return;
const statePath = resolveStatePath(ws);
if (!statePath) return;
const md = readFile(statePath);
if (!md) return;
if (buildSnapshot(md)) {
archiveState(ws, md, config);
log.info?.("[memory-continuity] Archived state before /new");
}
}, { priority: 10 });
// ------------------------------------------------------------------
// HOOK 4: agent_end — extract and save working state
// ------------------------------------------------------------------
api.on("agent_end", async (event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
if (!ws || config.autoExtract === false) return;
const statePath = resolveStatePath(ws);
if (!statePath) return;
const messages = event?.messages;
if (!messages || messages.length === 0) {
log.info?.("[memory-continuity] No messages in session, skipping");
return;
}
// Count real user messages (exclude system, metadata-only, short commands)
// Returns cleaned text strings so ignore-pattern regex can test against actual content.
const realUserMsgs = messages.reduce((acc, m) => {
if (m?.role !== "user") return acc;
const text = typeof m?.content === "string" ? m.content
: Array.isArray(m?.content) ? m.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();
// Skip very short messages like "/new", "/status", single-word queries
if (cleaned.length > 10) acc.push(cleaned);
return acc;
}, []);
// Check ignore patterns — skip sessions matching cron/subagent noise
const ignorePatterns = (config.ignorePatterns || [])
.map(p => { try { return new RegExp(p, "i"); } catch { log.warn?.("[memory-continuity] ignorePatterns: invalid regex, skipping: " + p); 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;
}
}
// Write session log entry
writeSessionLog(ws, messages, config);
// Generate daily summary for previous day if needed
generateDailySummary(ws, config);
generateWeeklySummary(ws, config);
decayOldArchives(ws, config);
const existing = readFile(statePath);
const newState = extractStateFromMessages(messages);
if (!newState) {
log.info?.("[memory-continuity] No extractable state from conversation");
return;
}
// Archive previous state if it exists
if (existing) {
archiveState(ws, existing, config);
}
writeFile(statePath, newState);
log.info?.("[memory-continuity] Updated state from conversation (" + realUserMsgs.length + " real msgs)");
}, { priority: 90 }); // low priority, run after other hooks
// ------------------------------------------------------------------
// HOOK 5: session_end — ensure state file exists
// ------------------------------------------------------------------
api.on("session_end", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const statePath = resolveStatePath(ws);
if (!statePath) return;
if (!readFile(statePath)) {
writeFile(statePath, STATE_TEMPLATE);
log.info?.("[memory-continuity] Created initial CURRENT_STATE.md");
}
}, { priority: 90 });
// ------------------------------------------------------------------
// 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
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");
}
log.info?.("[memory-continuity] Plugin registered successfully");
},
};
export default plugin;
+845
View File
@@ -0,0 +1,845 @@
/**
* 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)) {
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 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();
}
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 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`;
}
// ── 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 "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.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)"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"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"
}
}
+132
View File
@@ -0,0 +1,132 @@
{
"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",
"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": {
"type": "object",
"additionalProperties": false,
"properties": {
"maxStateLines": {
"type": "number",
"default": 50,
"description": "Max lines for CURRENT_STATE.md before auto-compress warning"
},
"archiveOnNew": {
"type": "boolean",
"default": true,
"description": "Archive CURRENT_STATE.md to session_archive/ on /new"
},
"autoExtract": {
"type": "boolean",
"default": true,
"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)"
}
}
},
"uiHints": {
"maxStateLines": {
"label": "Max state file lines",
"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)"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"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"
}
}
+4 -3
View File
@@ -15,6 +15,7 @@ import sys
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
@@ -32,7 +33,7 @@ class Severity:
# ---------------------------------------------------------------------------
class DiagnosticReport:
def __init__(self):
self.entries: list[tuple[str, str]] = []
self.entries: List[Tuple[str, str]] = []
self._worst = Severity.OK
def add(self, severity: str, message: str):
@@ -78,7 +79,7 @@ PLACEHOLDER_PATTERNS = [
# 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."""
state_file = workspace / "memory" / "CURRENT_STATE.md"
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:
"""Check all required sections are present and not placeholder-only."""
content = state_file.read_text(encoding="utf-8")
sections_found: dict[str, str] = {}
sections_found: Dict[str, str] = {}
for section in REQUIRED_SECTIONS:
# Match ## Section or ## Section\n
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# post-install.sh — Install memory-continuity as an OpenClaw lifecycle plugin
#
# This script:
# 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/
# 2. Adds the plugin entry to openclaw.json (if not present)
# 3. Detects OpenClaw agents and installs SKILL.md to selected workspaces
#
# Usage:
# bash scripts/post-install.sh
#
# Safe to run multiple times (idempotent).
set -euo pipefail
OPENCLAW_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
EXTENSIONS_DIR="$OPENCLAW_DIR/extensions"
PLUGIN_DIR="$EXTENSIONS_DIR/memory-continuity"
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
# Resolve the repo root (parent of scripts/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "=== memory-continuity plugin installer ==="
echo ""
# Step 1: Copy plugin files to extensions directory
echo "[1/3] Installing plugin to $PLUGIN_DIR ..."
mkdir -p "$EXTENSIONS_DIR"
# Remove old installation if exists
if [[ -d "$PLUGIN_DIR" || -L "$PLUGIN_DIR" ]]; then
rm -rf "$PLUGIN_DIR"
echo " Removed previous installation"
fi
# Copy essential plugin files (not the entire repo)
mkdir -p "$PLUGIN_DIR"
for f in index.js openclaw.plugin.json package.json SKILL.md; do
if [[ -f "$REPO_DIR/$f" ]]; then
cp "$REPO_DIR/$f" "$PLUGIN_DIR/"
fi
done
echo " Copied plugin files"
# Step 2: Add plugin entry to openclaw.json
echo "[2/3] Configuring openclaw.json ..."
if [[ ! -f "$CONFIG_FILE" ]]; then
echo " WARNING: $CONFIG_FILE not found. Skipping config update."
echo " You'll need to manually add the plugin entry."
else
python3 -c "
import json
path = '$CONFIG_FILE'
with open(path) as f:
data = json.load(f)
if 'plugins' not in data:
data['plugins'] = {}
allow_list = data['plugins'].get('allow', [])
if 'memory-continuity' not in allow_list:
allow_list.append('memory-continuity')
data['plugins']['allow'] = allow_list
if 'entries' not in data['plugins']:
data['plugins']['entries'] = {}
data['plugins']['entries']['memory-continuity'] = {
'enabled': True,
'hooks': {
'allowPromptInjection': True
},
'config': {
'maxStateLines': 50,
'archiveOnNew': True,
'autoExtract': True
}
}
if 'installs' not in data['plugins']:
data['plugins']['installs'] = {}
data['plugins']['installs']['memory-continuity'] = {
'source': 'path',
'installPath': '~/.openclaw/extensions/memory-continuity',
'sourcePath': '$REPO_DIR'
}
with open(path, 'w') as f:
json.dump(data, f, indent=2)
print(' Added plugin entry, trust config, and install record')
" 2>/dev/null || echo " WARNING: Could not update config automatically. Add manually."
fi
# ---------------------------------------------------------------------------
# Step 3: Detect agents and install SKILL.md to selected workspaces
# ---------------------------------------------------------------------------
echo "[3/3] Detecting OpenClaw agents ..."
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
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
else
# 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
echo ""
echo "🧙 启动配置向导..."
python3 "$(dirname "$0")/setup_wizard.py" || true
echo ""
echo "=== Installation complete ==="
echo ""
echo "Verify with:"
echo " bash scripts/verify.sh"
echo " bash scripts/verify.sh --all-agents"
echo ""
echo "Test with:"
echo " 1. Tell your agent something memorable"
echo " 2. Send /new"
echo " 3. Ask what you told it"
+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 ""