25 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
10 changed files with 2289 additions and 12 deletions
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## v3.0.0 — 2026-03-24
### Summary
Major release: **interactive `/mc` commands** for Telegram, Discord, and CLI. You can now inspect, search, restore, and manage agent memory directly from chat.
### Added
- **`/mc` slash command plugin** — 10 subcommands registered as a native OpenClaw gateway command
- `/mc state [agent]` — view current working state
- `/mc state --all` — overview of all agents' memory
- `/mc history [agent]` — list archived sessions
- `/mc restore <N> [agent]` — restore from archive
- `/mc clear [agent]` — clear state (auto-archives first)
- `/mc search <keyword>` — full-text search across all memory and archives
- `/mc settings` — view/update plugin configuration
- `/mc compact [agent]` — compress oversized state files
- `/mc export [agent|all]` — export memory to markdown file
- `/mc --help` — command reference
- **Multi-agent support** — all commands work across main + sub-agent workspaces
- **Monospace formatting** — output wrapped in code blocks for aligned display in Telegram/Discord
- **Auto-archive on clear** — clearing state automatically archives first, preventing data loss
- **Restore with backup** — restoring an archive backs up current state to `.bak`
### Changed
- Bumped version to 3.0.0
- Plugin now ships as two components: `memory-continuity` (lifecycle hooks) + `mc` (slash commands)
---
## v2.7.0 — 2026-03-22
### Changed
- Memory directory cleanup: auto-remove legacy `STATE_ARCHIVE_*.md` files
- Max memory files limit (500) with auto-cleanup of oldest files
- Archive count limit configurable via `maxArchiveCount`
---
## v2.3.0 — 2026-03-16
### Summary
+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.
+94 -1
View File
@@ -1,6 +1,6 @@
# memory-continuity
**Current release:** `v2.7.0`
**Current release:** `v4.0.0`
OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
@@ -46,6 +46,37 @@ Because state injection happens at the hook level (before the model sees anythin
## 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
@@ -92,6 +123,62 @@ openclaw gateway restart 2>&1 | grep memory-continuity
3. Ask "what was the secret?" or "我们刚才聊到哪了"
4. The agent should immediately surface the recovered state
## Interactive Commands (`/mc`)
v3.0.0 adds a companion plugin that registers `/mc` as a native slash command. Works in Telegram, Discord, and anywhere OpenClaw commands are supported.
```
/mc state View main agent's working state
/mc state --all Overview of all agents
/mc state tech_geek View a specific agent's state
/mc history List archived sessions
/mc restore 3 Restore archive #3
/mc search auth Search "auth" across all memory
/mc settings View plugin settings
/mc settings maxArchiveCount 30 Update a setting
/mc compact Compress oversized state file
/mc export all Export all agents' memory to file
/mc --help Command reference
```
The `/mc` plugin reads memory files directly — no HTTP endpoints, no model invocation. Responses are instant.
### Install the command plugin
The `/mc` command plugin is separate from the lifecycle plugin. To install:
```bash
# Clone (if you haven't already)
cd ~/.openclaw/projects
git clone https://github.com/dtzp555-max/memory-continuity.git
# The mc-plugin is bundled in the mc-plugin/ directory
cp -r memory-continuity/mc-plugin ~/.openclaw/projects/mc-plugin
mkdir -p ~/.openclaw/extensions/mc
cp ~/.openclaw/projects/mc-plugin/* ~/.openclaw/extensions/mc/
```
Then add to `openclaw.json`:
```json
{
"plugins": {
"allow": ["memory-continuity", "mc"],
"entries": {
"mc": { "enabled": true }
},
"installs": {
"mc": {
"source": "path",
"sourcePath": "~/.openclaw/projects/mc-plugin",
"installPath": "~/.openclaw/extensions/mc"
}
}
}
}
```
Restart the gateway and `/mc --help` should work.
## Configuration
The plugin works with zero configuration. Optional settings in `openclaw.json`:
@@ -255,6 +342,12 @@ memory-continuity/
4. **Portable and backupable**`cp` is your backup tool
5. **Complements native OpenClaw memory** — does not replace it
## Support
If you find this plugin useful, please consider giving it a ⭐ on GitHub — it helps others discover the project!
[![GitHub stars](https://img.shields.io/github/stars/dtzp555-max/memory-continuity?style=social)](https://github.com/dtzp555-max/memory-continuity)
## License
MIT
@@ -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"`.
+627 -8
View File
@@ -14,6 +14,37 @@ const PLACEHOLDER_VALUES = new Set([
"[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()}
@@ -139,6 +170,470 @@ function cleanupMemoryDir(workspaceDir) {
} 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;
@@ -171,8 +666,24 @@ function extractStateFromMessages(messages) {
const lastUser = userMessages[userMessages.length - 1] || "";
const lastAssistant = assistantMessages[assistantMessages.length - 1] || "";
// Truncate to keep it compact
const truncate = (s, max = 200) => s.length > max ? s.slice(0, max) + "..." : s;
// 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()}
@@ -214,6 +725,7 @@ const plugin = {
// ------------------------------------------------------------------
api.on("before_agent_start", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws);
if (!statePath) return;
@@ -225,9 +737,22 @@ const plugin = {
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:
snapshot + "\n\n" +
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.",
@@ -239,6 +764,7 @@ const plugin = {
// ------------------------------------------------------------------
api.on("before_compaction", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws);
if (!statePath) return;
@@ -250,8 +776,19 @@ const plugin = {
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: snapshot,
prependSystemContext: parts.join("\n\n"),
};
}, { priority: 10 });
@@ -293,8 +830,9 @@ const plugin = {
}
// Count real user messages (exclude system, metadata-only, short commands)
const realUserMsgs = messages.filter(m => {
if (m?.role !== "user") return false;
// 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")
: "";
@@ -303,8 +841,30 @@ const plugin = {
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim();
// Skip very short messages like "/new", "/status", single-word queries
return cleaned.length > 10;
});
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);
@@ -336,6 +896,65 @@ const plugin = {
}
}, { 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");
},
};
+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"
}
}
+82 -1
View File
@@ -2,8 +2,26 @@
"id": "memory-continuity",
"name": "Memory Continuity",
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
"version": "2.7.0",
"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,
@@ -27,6 +45,41 @@
"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)"
}
}
},
@@ -46,6 +99,34 @@
"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)"
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "memory-continuity",
"version": "2.7.0",
"version": "4.0.0",
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js",
"type": "module",
@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git"
},
"keywords": ["openclaw", "plugin", "memory", "continuity"],
"keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max",
"license": "MIT",
"openclaw": {