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 # 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 ## v2.3.0 — 2026-03-16
### Summary ### 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 # 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. OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
@@ -46,6 +46,37 @@ Because state injection happens at the hook level (before the model sees anythin
## Quick Start ## Quick Start
### Marketplace Install (recommended)
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
### Works with lossless-claw
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
- **lossless-claw** = lossless context compression (contextEngine slot)
- **memory-continuity** = working-state recovery (hooks only)
Install both for the best experience.
### Programmatic API
Other plugins can call MC's recall function programmatically:
```javascript
// In another plugin's register() function:
const recall = api.getService("mc:recall");
if (recall) {
const result = await recall.handler(
{ topic: "deployment issues", maxItems: 3 },
ctx
);
// result.results = [{ date, type, score, summary }, ...]
}
```
### Install ### Install
```bash ```bash
@@ -92,6 +123,62 @@ openclaw gateway restart 2>&1 | grep memory-continuity
3. Ask "what was the secret?" or "我们刚才聊到哪了" 3. Ask "what was the secret?" or "我们刚才聊到哪了"
4. The agent should immediately surface the recovered state 4. The agent should immediately surface the recovered state
## Interactive Commands (`/mc`)
v3.0.0 adds a companion plugin that registers `/mc` as a native slash command. Works in Telegram, Discord, and anywhere OpenClaw commands are supported.
```
/mc state View main agent's working state
/mc state --all Overview of all agents
/mc state tech_geek View a specific agent's state
/mc history List archived sessions
/mc restore 3 Restore archive #3
/mc search auth Search "auth" across all memory
/mc settings View plugin settings
/mc settings maxArchiveCount 30 Update a setting
/mc compact Compress oversized state file
/mc export all Export all agents' memory to file
/mc --help Command reference
```
The `/mc` plugin reads memory files directly — no HTTP endpoints, no model invocation. Responses are instant.
### Install the command plugin
The `/mc` command plugin is separate from the lifecycle plugin. To install:
```bash
# Clone (if you haven't already)
cd ~/.openclaw/projects
git clone https://github.com/dtzp555-max/memory-continuity.git
# The mc-plugin is bundled in the mc-plugin/ directory
cp -r memory-continuity/mc-plugin ~/.openclaw/projects/mc-plugin
mkdir -p ~/.openclaw/extensions/mc
cp ~/.openclaw/projects/mc-plugin/* ~/.openclaw/extensions/mc/
```
Then add to `openclaw.json`:
```json
{
"plugins": {
"allow": ["memory-continuity", "mc"],
"entries": {
"mc": { "enabled": true }
},
"installs": {
"mc": {
"source": "path",
"sourcePath": "~/.openclaw/projects/mc-plugin",
"installPath": "~/.openclaw/extensions/mc"
}
}
}
}
```
Restart the gateway and `/mc --help` should work.
## Configuration ## Configuration
The plugin works with zero configuration. Optional settings in `openclaw.json`: The plugin works with zero configuration. Optional settings in `openclaw.json`:
@@ -255,6 +342,12 @@ memory-continuity/
4. **Portable and backupable**`cp` is your backup tool 4. **Portable and backupable**`cp` is your backup tool
5. **Complements native OpenClaw memory** — does not replace it 5. **Complements native OpenClaw memory** — does not replace it
## Support
If you find this plugin useful, please consider giving it a ⭐ on GitHub — it helps others discover the project!
[![GitHub stars](https://img.shields.io/github/stars/dtzp555-max/memory-continuity?style=social)](https://github.com/dtzp555-max/memory-continuity)
## License ## License
MIT MIT
@@ -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]", "[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 const STATE_TEMPLATE = `# Current State
> Last updated: ${new Date().toISOString()} > Last updated: ${new Date().toISOString()}
@@ -139,6 +170,470 @@ function cleanupMemoryDir(workspaceDir) {
} 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) { function extractStateFromMessages(messages) {
if (!messages || messages.length === 0) return null; if (!messages || messages.length === 0) return null;
@@ -171,8 +666,24 @@ function extractStateFromMessages(messages) {
const lastUser = userMessages[userMessages.length - 1] || ""; const lastUser = userMessages[userMessages.length - 1] || "";
const lastAssistant = assistantMessages[assistantMessages.length - 1] || ""; const lastAssistant = assistantMessages[assistantMessages.length - 1] || "";
// Truncate to keep it compact // Token-aware truncation
const truncate = (s, max = 200) => s.length > max ? s.slice(0, max) + "..." : s; 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 return `# Current State
> Last updated: ${new Date().toISOString()} > Last updated: ${new Date().toISOString()}
@@ -214,6 +725,7 @@ const plugin = {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
api.on("before_agent_start", async (_event, _ctx) => { api.on("before_agent_start", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir; const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws); const statePath = resolveStatePath(ws);
if (!statePath) return; if (!statePath) return;
@@ -225,9 +737,22 @@ const plugin = {
log.info?.("[memory-continuity] Injecting recovered state into context"); 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 { return {
prependSystemContext: prependSystemContext:
snapshot + "\n\n" + parts.join("\n\n") + "\n\n" +
"IMPORTANT: The above is recovered working state from a previous session. " + "IMPORTANT: The above is recovered working state from a previous session. " +
"If the user appears to be resuming work, surface this state immediately " + "If the user appears to be resuming work, surface this state immediately " +
"before any generic greeting. This is a continuity requirement, not optional.", "before any generic greeting. This is a continuity requirement, not optional.",
@@ -239,6 +764,7 @@ const plugin = {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
api.on("before_compaction", async (_event, _ctx) => { api.on("before_compaction", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir; const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws); const statePath = resolveStatePath(ws);
if (!statePath) return; if (!statePath) return;
@@ -250,8 +776,19 @@ const plugin = {
log.info?.("[memory-continuity] Injecting state before compaction"); 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 { return {
prependSystemContext: snapshot, prependSystemContext: parts.join("\n\n"),
}; };
}, { priority: 10 }); }, { priority: 10 });
@@ -293,8 +830,9 @@ const plugin = {
} }
// Count real user messages (exclude system, metadata-only, short commands) // Count real user messages (exclude system, metadata-only, short commands)
const realUserMsgs = messages.filter(m => { // Returns cleaned text strings so ignore-pattern regex can test against actual content.
if (m?.role !== "user") return false; const realUserMsgs = messages.reduce((acc, m) => {
if (m?.role !== "user") return acc;
const text = typeof m?.content === "string" ? m.content 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") : 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, "") .replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim(); .trim();
// Skip very short messages like "/new", "/status", single-word queries // 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 existing = readFile(statePath);
const newState = extractStateFromMessages(messages); const newState = extractStateFromMessages(messages);
@@ -336,6 +896,65 @@ const plugin = {
} }
}, { priority: 90 }); }, { 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"); 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", "id": "memory-continuity",
"name": "Memory Continuity", "name": "Memory Continuity",
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.", "description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
"version": "2.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", "source": "https://github.com/dtzp555-max/memory-continuity",
"interop": {
"complements": [
{
"id": "lossless-claw",
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
}
],
"conflicts": [],
"slot": "hooks-only",
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -27,6 +45,41 @@
"type": "number", "type": "number",
"default": 20, "default": 20,
"description": "Maximum number of archive files to keep in session_archive/" "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": { "maxArchiveCount": {
"label": "Max archive files", "label": "Max archive files",
"help": "Old archives are auto-deleted when this limit is reached" "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", "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.", "description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -8,7 +8,7 @@
"type": "git", "type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git" "url": "https://github.com/dtzp555-max/memory-continuity.git"
}, },
"keywords": ["openclaw", "plugin", "memory", "continuity"], "keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max", "author": "dtzp555-max",
"license": "MIT", "license": "MIT",
"openclaw": { "openclaw": {