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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:26:53 +10:00
Oracle Public Cloud User e9faf21d0c docs: add GitHub stars badge and support section 2026-03-22 20:51:45 +00:00
14 changed files with 4515 additions and 14 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,566 @@
# v3.1 Short-Term Memory Layer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add session logging, smart tail protection during compaction, CJK token awareness, and noise filtering to the Memory Continuity plugin.
**Architecture:** Four independent features added to the existing lifecycle hook system. Session logs write to `memory/sessions/YYYY-MM-DD.md` (one file per day, append-only). Tail protection injects recent critical messages into `before_compaction`. CJK token estimation uses a helper function for line-count decisions. Ignore patterns filter out cron/subagent noise at `agent_end`.
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `index.js` | Modify | Add session logging in `agent_end`, enhance `before_compaction` with tail protection, add CJK helper, add ignore-pattern filtering |
| `openclaw.plugin.json` | Modify | Add new config keys: `sessionLogging`, `tailProtectCount`, `ignorePatterns` |
| `mc-plugin/index.js` | Modify | Add `/mc sessions` subcommand, update help text |
| `mc-plugin/openclaw.plugin.json` | No change | — |
---
### Task 1: CJK Token Estimation Helper
**Files:**
- Modify: `index.js` (after line 15, the PLACEHOLDER_VALUES block)
- [ ] **Step 1: Add `estimateTokens` helper function to `index.js`**
Add this after the `PLACEHOLDER_VALUES` constant (line 15):
```javascript
/**
* Estimate token count with CJK awareness.
* CJK characters ≈ 1.5 tokens each; Latin words ≈ 1 token per ~4 chars.
*/
function estimateTokens(text) {
if (!text) return 0;
// Count CJK characters (CJK Unified Ideographs + common CJK ranges)
const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g) || []).length;
// Remove CJK chars, count remaining as ~1 token per 4 chars
const nonCjk = text.replace(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g, "");
const latinTokens = Math.ceil(nonCjk.length / 4);
return Math.ceil(cjkCount * 1.5) + latinTokens;
}
```
- [ ] **Step 2: Update `truncate` in `extractStateFromMessages` to use token-aware truncation**
In `extractStateFromMessages` (around line 175), replace the existing `truncate` function:
```javascript
// Token-aware truncation
const truncate = (s, maxTokens = 200) => {
if (estimateTokens(s) <= maxTokens) return s;
// Binary search for the right cut point
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (estimateTokens(s.slice(0, mid)) <= maxTokens) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo) + "...";
};
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: add CJK token-aware estimation helper (v3.1)"
```
---
### Task 2: Ignore Patterns — Filter Cron/Subagent Noise
**Files:**
- Modify: `index.js` (agent_end hook, around line 295)
- Modify: `openclaw.plugin.json` (add `ignorePatterns` config)
- [ ] **Step 1: Add `ignorePatterns` to `openclaw.plugin.json` config schema**
Add after the `maxArchiveCount` property block (after line 29):
```json
"ignorePatterns": {
"type": "array",
"default": [],
"description": "Regex patterns to ignore sessions (e.g. cron jobs, subagent noise). Matched against first user message."
}
```
And add corresponding uiHints after the `maxArchiveCount` hint (after line 47):
```json
"ignorePatterns": {
"label": "Ignore patterns",
"help": "Skip state extraction for sessions matching these patterns (regex)"
}
```
- [ ] **Step 2: Add ignore-pattern check in `agent_end` hook in `index.js`**
In the `agent_end` hook, after the `realUserMsgs` filtering block (after line 321), add:
```javascript
// Check ignore patterns — skip sessions matching cron/subagent noise
const ignorePatterns = (config.ignorePatterns || [])
.map(p => { try { return new RegExp(p, "i"); } catch { return null; } })
.filter(Boolean);
if (ignorePatterns.length > 0 && realUserMsgs.length > 0) {
const firstMsg = realUserMsgs[0];
if (ignorePatterns.some(re => re.test(firstMsg))) {
log.info?.("[memory-continuity] Session matches ignorePattern, skipping");
return;
}
}
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 4: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: add ignorePatterns to filter cron/subagent noise (v3.1)"
```
---
### Task 3: Session Logging
**Files:**
- Modify: `index.js` (add `writeSessionLog` helper + call it in `agent_end`)
- [ ] **Step 1: Add `writeSessionLog` helper function to `index.js`**
Add after the `cleanupMemoryDir` function (after line 140):
```javascript
/**
* Append a session summary to the daily session log.
* File: memory/sessions/YYYY-MM-DD.md (one per day, append-only)
*/
function writeSessionLog(workspaceDir, messages, config = {}) {
if (config.sessionLogging === false) return;
if (!messages || messages.length === 0) return;
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const sessionsDir = path.join(workspaceDir, "memory", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
const logFile = path.join(sessionsDir, `${dateStr}.md`);
// Extract first meaningful user message as topic
let topic = "(no topic)";
for (const msg of messages) {
if (msg?.role !== "user") continue;
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n")
: "";
const cleaned = text
.replace(/^Conversation info \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim();
if (cleaned.length > 10) {
topic = cleaned.split("\n")[0].slice(0, 120);
break;
}
}
// Count messages by role
const userCount = messages.filter(m => m?.role === "user").length;
const assistantCount = messages.filter(m => m?.role === "assistant").length;
const totalTokens = estimateTokens(
messages.map(m => typeof m?.content === "string" ? m.content : "").join("")
);
// Build log entry
const entry = [
`### ${timeStr}`,
`- **Topic:** ${topic}`,
`- **Messages:** ${userCount} user / ${assistantCount} assistant`,
`- **Est. tokens:** ~${totalTokens}`,
"",
].join("\n");
// Append to daily log (create with header if new)
if (!fs.existsSync(logFile)) {
const header = `# Session Log — ${dateStr}\n\n`;
fs.writeFileSync(logFile, header + entry, "utf8");
} else {
fs.appendFileSync(logFile, entry, "utf8");
}
}
```
- [ ] **Step 2: Add `sessionLogging` config to `openclaw.plugin.json`**
Add after the `ignorePatterns` property in the config schema:
```json
"sessionLogging": {
"type": "boolean",
"default": true,
"description": "Write session summaries to memory/sessions/ daily logs"
}
```
And add corresponding uiHints:
```json
"sessionLogging": {
"label": "Session logging",
"help": "Append session summaries to daily markdown logs"
}
```
- [ ] **Step 3: Call `writeSessionLog` from `agent_end` hook**
In the `agent_end` hook, right before the `const existing = readFile(statePath);` line (around line 324), add:
```javascript
// Write session log entry
writeSessionLog(ws, messages, config);
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: add session logging to memory/sessions/ daily files (v3.1)"
```
---
### Task 4: Smart Tail Protection in `before_compaction`
**Files:**
- Modify: `index.js` (enhance `before_compaction` hook, around line 254)
- Modify: `openclaw.plugin.json` (add `tailProtectCount` config)
- [ ] **Step 1: Add `tailProtectCount` config to `openclaw.plugin.json`**
Add after the `sessionLogging` property in the config schema:
```json
"tailProtectCount": {
"type": "number",
"default": 3,
"description": "Number of recent critical message pairs to protect during compaction"
}
```
And add corresponding uiHints:
```json
"tailProtectCount": {
"label": "Tail protect count",
"help": "Keep N recent user/assistant pairs visible after compaction"
}
```
- [ ] **Step 2: Add `extractTailMessages` helper to `index.js`**
Add after the `writeSessionLog` function:
```javascript
/**
* Extract the last N meaningful user/assistant exchange pairs from messages.
* Returns a formatted string for injection into compaction context.
*/
function extractTailMessages(messages, count = 3) {
if (!messages || messages.length === 0) return null;
// Walk backwards, collect up to `count` user+assistant pairs
const pairs = [];
let i = messages.length - 1;
while (i >= 0 && pairs.length < count) {
// Find assistant message
while (i >= 0 && messages[i]?.role !== "assistant") i--;
if (i < 0) break;
const assistantMsg = messages[i];
i--;
// Find preceding user message
while (i >= 0 && messages[i]?.role !== "user") i--;
if (i < 0) break;
const userMsg = messages[i];
i--;
const getText = (msg) => {
if (typeof msg?.content === "string") return msg.content;
if (Array.isArray(msg?.content)) {
return msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n");
}
return "";
};
const userText = getText(userMsg).trim();
const assistantText = getText(assistantMsg).trim();
// Skip trivial exchanges
if (userText.length < 10 && assistantText.length < 20) continue;
// Token-aware truncation per message
const maxPerMsg = 150;
const truncMsg = (s) => {
if (estimateTokens(s) <= maxPerMsg) return s;
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (estimateTokens(s.slice(0, mid)) <= maxPerMsg) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo) + "...";
};
pairs.unshift({ user: truncMsg(userText), assistant: truncMsg(assistantText) });
}
if (pairs.length === 0) return null;
const lines = ["=== RECENT EXCHANGE (protected) ==="];
for (const p of pairs) {
lines.push(`User: ${p.user}`);
lines.push(`Assistant: ${p.assistant}`);
lines.push("---");
}
lines.push("=== END RECENT EXCHANGE ===");
return lines.join("\n");
}
```
- [ ] **Step 3: Enhance `before_compaction` hook to inject tail messages**
Replace the existing `before_compaction` hook body (lines 254-270) with:
```javascript
api.on("before_compaction", async (_event, _ctx) => {
const ws = _ctx?.workspaceDir;
const config = getConfig();
const statePath = resolveStatePath(ws);
if (!statePath) return;
const md = readFile(statePath);
if (!md) return;
const snapshot = buildSnapshot(md);
if (!snapshot) return;
log.info?.("[memory-continuity] Injecting state before compaction");
// Smart tail protection: also inject recent critical messages
const tailCount = config.tailProtectCount ?? 3;
const messages = _event?.messages;
const tail = tailCount > 0 ? extractTailMessages(messages, tailCount) : null;
const parts = [snapshot];
if (tail) {
parts.push(tail);
log.info?.("[memory-continuity] Tail protection: injected recent exchanges");
}
return {
prependSystemContext: parts.join("\n\n"),
};
}, { priority: 10 });
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: smart tail protection during compaction (v3.1)"
```
---
### Task 5: `/mc sessions` Command
**Files:**
- Modify: `mc-plugin/index.js` (add `cmdSessions` function + wire into switch)
- [ ] **Step 1: Add `cmdSessions` handler to `mc-plugin/index.js`**
Add after the `cmdExport` function (before `cmdHelp`, around line 437):
```javascript
function cmdSessions(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const sessionsDir = path.join(memDir, "sessions");
let files;
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No session logs for "${agent}".`; }
if (!files.length) return `No session logs for "${agent}".`;
// If date specified, show that day's log
if (dateArg) {
const target = `${dateArg}.md`;
const content = readFile(path.join(sessionsDir, target));
if (!content) return `No session log for ${dateArg}.`;
// Truncate to last 50 lines to stay compact
const lines = content.split("\n");
const shown = lines.length > 50 ? lines.slice(-50) : lines;
let out = shown.join("\n");
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
return out;
}
// List recent session logs
let out = `Session Logs: ${agent} (${files.length} days)\n`;
out += "─────────────────────────────\n";
for (const f of files.slice(0, 14)) {
const date = f.replace(".md", "");
const content = readFile(path.join(sessionsDir, f));
// Count session entries (### HH:MM headers)
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
}
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
return out.trimEnd();
}
```
- [ ] **Step 2: Wire `sessions` into the command switch**
In the switch statement (around line 471), add a new case before `help`:
```javascript
case "sessions": text = cmdSessions(subargs || null); break;
```
- [ ] **Step 3: Update `cmdHelp` to include `sessions`**
Add this line in the help text after the `/mc state --all` line:
```
/mc sessions [date] Session logs (daily activity)
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: add /mc sessions command for daily session logs (v3.1)"
```
---
### Task 6: Version Bump and Final Verification
**Files:**
- Modify: `openclaw.plugin.json` (version 3.0.0 → 3.1.0)
- Modify: `mc-plugin/openclaw.plugin.json` (version if present)
- Modify: `package.json` (version if present)
- [ ] **Step 1: Bump version in `openclaw.plugin.json` to 3.1.0**
Change `"version": "3.0.0"` to `"version": "3.1.0"`.
- [ ] **Step 2: Bump version in `package.json` to 3.1.0**
Change `"version"` value to `"3.1.0"`.
- [ ] **Step 3: Verify both plugins parse correctly**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
```
Expected: `All OK`
- [ ] **Step 4: Commit and tag**
```bash
git add openclaw.plugin.json package.json
git commit -m "release: v3.1.0 — short-term memory layer"
```
- [ ] **Step 5: Push to GitHub**
```bash
git push
```
---
## Summary of New Config Keys
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `ignorePatterns` | `string[]` | `[]` | Regex patterns to skip sessions (cron/subagent) |
| `sessionLogging` | `boolean` | `true` | Enable daily session log files |
| `tailProtectCount` | `number` | `3` | Recent exchange pairs to protect during compaction |
## Summary of New/Changed Hooks
| Hook | Change |
|------|--------|
| `agent_end` | +session logging, +ignore pattern filtering, +CJK-aware truncation |
| `before_compaction` | +tail message protection |
## New Command
| Command | Purpose |
|---------|---------|
| `/mc sessions [date]` | List daily session logs or view specific day |
@@ -0,0 +1,674 @@
# v3.2 Mid-Term Memory Layer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add layered markdown summaries (daily/weekly), enhanced cross-source search, and automatic tag extraction to the Memory Continuity plugin.
**Architecture:** Three independent features layered on v3.1's session logging. Daily summaries are generated from session logs at `agent_end` when the day changes. Weekly summaries roll up dailies every Monday. Enhanced search extends the existing `/mc search` to cover sessions + summaries with context lines. Auto-tagging scans session entries for `#tag` patterns and maintains a `memory/tags.md` index. All pure markdown, zero dependencies.
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `index.js` | Modify | Add `generateDailySummary`, `generateWeeklySummary`, call from `agent_end` |
| `openclaw.plugin.json` | Modify | Add `summaryEnabled` config |
| `mc-plugin/index.js` | Modify | Enhance `cmdSearch` to cover sessions+summaries, add `/mc tags` and `/mc summary` commands |
---
### Task 1: Daily Summary Generation
**Files:**
- Modify: `index.js` (add helper + call in agent_end)
- Modify: `openclaw.plugin.json` (add `summaryEnabled` config)
- [ ] **Step 1: Add `summaryEnabled` config to `openclaw.plugin.json`**
Read `openclaw.plugin.json`. Add after the `tailProtectCount` property in configSchema:
```json
"summaryEnabled": {
"type": "boolean",
"default": true,
"description": "Generate daily and weekly summaries from session logs"
}
```
Add corresponding uiHints after the `tailProtectCount` hint:
```json
"summaryEnabled": {
"label": "Summary generation",
"help": "Auto-generate daily/weekly summaries from session logs"
}
```
- [ ] **Step 2: Add `generateDailySummary` helper to `index.js`**
Read `index.js`. Add after the `extractTailMessages` function (ends around line 302), before `extractStateFromMessages`:
```javascript
/**
* Generate a daily summary from the previous day's session log.
* Only runs when today differs from the last summary date.
* Output: memory/summaries/daily/YYYY-MM-DD.md
*/
function generateDailySummary(workspaceDir, config = {}) {
if (config.summaryEnabled === false) return;
const pad = (n) => String(n).padStart(2, "0");
const now = new Date();
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
// Check yesterday's date
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${pad(yesterday.getMonth() + 1)}-${pad(yesterday.getDate())}`;
const summaryDir = path.join(workspaceDir, "memory", "summaries", "daily");
const summaryFile = path.join(summaryDir, `${yStr}.md`);
// Skip if summary already exists for yesterday
if (fs.existsSync(summaryFile)) return;
// Read yesterday's session log
const sessionFile = path.join(workspaceDir, "memory", "sessions", `${yStr}.md`);
const sessionContent = readFile(sessionFile);
if (!sessionContent) return; // No sessions yesterday
// Parse session entries
const entries = sessionContent.split(/^### /gm).filter(e => e.trim());
if (entries.length === 0) return;
const topics = [];
let totalUser = 0;
let totalAssistant = 0;
let totalTokens = 0;
for (const entry of entries) {
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const msgMatch = entry.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
const tokenMatch = entry.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
if (topicMatch) topics.push(topicMatch[1].trim());
if (msgMatch) {
totalUser += parseInt(msgMatch[1]);
totalAssistant += parseInt(msgMatch[2]);
}
if (tokenMatch) totalTokens += parseInt(tokenMatch[1]);
}
// Build daily summary
const summary = [
`# Daily Summary — ${yStr}`,
"",
`**Sessions:** ${entries.length}`,
`**Messages:** ${totalUser} user / ${totalAssistant} assistant`,
`**Est. tokens:** ~${totalTokens}`,
"",
"## Topics",
...topics.map((t, i) => `${i + 1}. ${t}`),
"",
].join("\n");
fs.mkdirSync(summaryDir, { recursive: true });
fs.writeFileSync(summaryFile, summary, "utf8");
}
```
- [ ] **Step 3: Call `generateDailySummary` from `agent_end` hook**
In the `agent_end` hook, right after the `writeSessionLog(ws, messages, config);` line (around line 515), add:
```javascript
// Generate daily summary for previous day if needed
generateDailySummary(ws, config);
```
- [ ] **Step 4: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
Expected: no output (syntax OK)
- [ ] **Step 5: Commit**
```bash
git add index.js openclaw.plugin.json
git commit -m "feat: auto-generate daily summaries from session logs (v3.2)"
```
---
### Task 2: Weekly Summary Generation
**Files:**
- Modify: `index.js` (add `generateWeeklySummary` + call in agent_end)
- [ ] **Step 1: Add `generateWeeklySummary` helper to `index.js`**
Read `index.js`. Add after the `generateDailySummary` function:
```javascript
/**
* Generate a weekly summary by rolling up daily summaries.
* Runs on Monday, summarizing the previous week (Mon-Sun).
* Output: memory/summaries/weekly/YYYY-Www.md (ISO week number)
*/
function generateWeeklySummary(workspaceDir, config = {}) {
if (config.summaryEnabled === false) return;
const now = new Date();
// Only run on Mondays
if (now.getDay() !== 1) return;
const pad = (n) => String(n).padStart(2, "0");
// Calculate previous week's Monday
const prevMonday = new Date(now);
prevMonday.setDate(prevMonday.getDate() - 7);
// ISO week number
const jan1 = new Date(prevMonday.getFullYear(), 0, 1);
const weekNum = Math.ceil(((prevMonday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
const weekLabel = `${prevMonday.getFullYear()}-W${pad(weekNum)}`;
const weeklyDir = path.join(workspaceDir, "memory", "summaries", "weekly");
const weeklyFile = path.join(weeklyDir, `${weekLabel}.md`);
// Skip if already generated
if (fs.existsSync(weeklyFile)) return;
// Collect daily summaries for the 7 days of previous week
const dailyDir = path.join(workspaceDir, "memory", "summaries", "daily");
const dailies = [];
let weekSessions = 0;
let weekUser = 0;
let weekAssistant = 0;
let weekTokens = 0;
const allTopics = [];
for (let d = 0; d < 7; d++) {
const day = new Date(prevMonday);
day.setDate(day.getDate() + d);
const dayStr = `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
const dailyFile = path.join(dailyDir, `${dayStr}.md`);
const content = readFile(dailyFile);
if (!content) continue;
dailies.push(dayStr);
const sessMatch = content.match(/\*\*Sessions:\*\*\s*(\d+)/);
const msgMatch = content.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
const tokenMatch = content.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
if (sessMatch) weekSessions += parseInt(sessMatch[1]);
if (msgMatch) {
weekUser += parseInt(msgMatch[1]);
weekAssistant += parseInt(msgMatch[2]);
}
if (tokenMatch) weekTokens += parseInt(tokenMatch[1]);
// Extract topics
const topicSection = content.split("## Topics")[1];
if (topicSection) {
const topics = topicSection.match(/^\d+\.\s+(.+)$/gm);
if (topics) allTopics.push(...topics.map(t => t.replace(/^\d+\.\s+/, "").trim()));
}
}
if (dailies.length === 0) return;
// Deduplicate topics (keep first occurrence)
const seen = new Set();
const uniqueTopics = allTopics.filter(t => {
const key = t.toLowerCase().slice(0, 50);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const summary = [
`# Weekly Summary — ${weekLabel}`,
`> ${dailies[0]} to ${dailies[dailies.length - 1]}`,
"",
`**Active days:** ${dailies.length}/7`,
`**Total sessions:** ${weekSessions}`,
`**Total messages:** ${weekUser} user / ${weekAssistant} assistant`,
`**Est. total tokens:** ~${weekTokens}`,
"",
"## Key Topics",
...uniqueTopics.slice(0, 20).map((t, i) => `${i + 1}. ${t}`),
"",
].join("\n");
fs.mkdirSync(weeklyDir, { recursive: true });
fs.writeFileSync(weeklyFile, summary, "utf8");
}
```
- [ ] **Step 2: Call `generateWeeklySummary` from `agent_end` hook**
Right after the `generateDailySummary(ws, config);` line, add:
```javascript
generateWeeklySummary(ws, config);
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: auto-generate weekly summaries from daily rollups (v3.2)"
```
---
### Task 3: Auto-Tagging — Extract `#tag` from Session Logs
**Files:**
- Modify: `index.js` (add `updateTagIndex` helper + call in `writeSessionLog`)
- [ ] **Step 1: Add `updateTagIndex` helper to `index.js`**
Read `index.js`. Add after the `generateWeeklySummary` function:
```javascript
/**
* Extract #tags from a session topic and update the tag index.
* Tags file: memory/tags.md — simple markdown index mapping tags to dates.
*/
function updateTagIndex(workspaceDir, topic, dateStr) {
if (!topic) return;
// Extract #tags (word chars + hyphens after #)
const tags = topic.match(/#[\w-]+/g);
if (!tags || tags.length === 0) return;
const tagsFile = path.join(workspaceDir, "memory", "tags.md");
let existing = readFile(tagsFile) || "# Tag Index\n\n";
for (const tag of tags) {
const normalizedTag = tag.toLowerCase();
// Check if this tag+date combo already exists
if (existing.includes(`${normalizedTag}`) && existing.includes(dateStr)) continue;
// Find or create tag section
const tagHeader = `## ${normalizedTag}`;
if (existing.includes(tagHeader)) {
// Append date to existing tag section
existing = existing.replace(
tagHeader,
`${tagHeader}\n- ${dateStr}`
);
} else {
// Add new tag section
existing += `${tagHeader}\n- ${dateStr}\n\n`;
}
}
writeFile(tagsFile, existing);
}
```
- [ ] **Step 2: Call `updateTagIndex` from `writeSessionLog`**
In the `writeSessionLog` function, right after the `fs.appendFileSync(logFile, prefix + entry, "utf8");` line (the last line of the function), add:
```javascript
// Extract and index #tags from the topic
updateTagIndex(workspaceDir, topic, dateStr);
```
- [ ] **Step 3: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
```
- [ ] **Step 4: Commit**
```bash
git add index.js
git commit -m "feat: auto-extract #tags from sessions to memory/tags.md (v3.2)"
```
---
### Task 4: Enhanced Search — Cross Sessions + Summaries
**Files:**
- Modify: `mc-plugin/index.js` (enhance `cmdSearch`)
- [ ] **Step 1: Enhance `cmdSearch` to search sessions and summaries**
Read `mc-plugin/index.js`. Replace the entire `cmdSearch` function (lines 220-279) with:
```javascript
function cmdSearch(args) {
if (!args) return "Usage: /mc search <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
const parts = args.trim().split(/\s+/);
let keyword, agent;
const agents = discoverAgents();
const agentNames = new Set(agents.map(a => a.name));
if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) {
agent = parts.pop();
keyword = parts.join(" ");
} else {
keyword = parts.join(" ");
agent = null;
}
const re = new RegExp(keyword, "gi");
const results = [];
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
for (const { name, memDir } of searchAgents) {
if (!memDir) continue;
// 1. Search current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && re.test(state)) {
re.lastIndex = 0;
const lines = state.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
}
// 2. Search archives (most recent 30)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 30)) {
re.lastIndex = 0;
const content = readFile(path.join(archiveDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
}
}
} catch {}
// 3. Search session logs (most recent 14 days)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
re.lastIndex = 0;
const content = readFile(path.join(sessionsDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
}
}
} catch {}
// 4. Search summaries (daily + weekly)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 10)) {
re.lastIndex = 0;
const content = readFile(path.join(sumDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
}
}
} catch {}
}
}
if (!results.length) return `No matches for "${keyword}".`;
// Group by type for clearer output
let out = `Search: "${keyword}" (${results.length} hits)\n`;
out += "─────────────────────────────\n";
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
for (const type of typeOrder) {
const group = results.filter(r => r.type === type);
if (group.length === 0) continue;
out += `\n[${typeLabels[type]}]\n`;
for (const r of group.slice(0, 5)) {
out += ` ${r.agent}/${r.source}\n`;
for (const line of r.matches) {
out += ` ${truncate(line.trim(), 75)}\n`;
}
}
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
}
return out.trimEnd();
}
```
- [ ] **Step 2: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
- [ ] **Step 3: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: enhanced search across sessions and summaries (v3.2)"
```
---
### Task 5: `/mc tags` and `/mc summary` Commands
**Files:**
- Modify: `mc-plugin/index.js` (add two command handlers + wire switch + update help)
- [ ] **Step 1: Add `cmdTags` handler to `mc-plugin/index.js`**
Read `mc-plugin/index.js`. Add before the `cmdHelp` function:
```javascript
function cmdTags(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const tagsFile = path.join(memDir, "tags.md");
const content = readFile(tagsFile);
if (!content) return `No tags for "${agent}".`;
// Parse tags and their date counts
const tagSections = content.split(/^## /gm).filter(s => s.trim());
if (tagSections.length === 0) return `No tags for "${agent}".`;
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
out += "─────────────────────────────\n";
const tags = [];
for (const section of tagSections) {
const lines = section.trim().split("\n");
const tag = lines[0].trim();
if (!tag.startsWith("#")) continue;
const dates = lines.filter(l => l.startsWith("- "));
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
}
// Sort by count descending
tags.sort((a, b) => b.count - a.count);
for (const { tag, count, latest } of tags.slice(0, 30)) {
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
}
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
return out.trimEnd();
}
```
- [ ] **Step 2: Add `cmdSummary` handler**
Add after `cmdTags`:
```javascript
function cmdSummary(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
const typeArg = parts.find(p => p === "daily" || p === "weekly");
const dateArg = parts.find(p => /^\d{4}/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
// If specific date given, show that summary
if (dateArg) {
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
const content = readFile(sumFile);
if (!content) return `No ${type} summary for ${dateArg}.`;
return content;
}
// List available summaries
const types = typeArg ? [typeArg] : ["daily", "weekly"];
let out = `Summaries: ${agent}\n`;
out += "─────────────────────────────\n";
for (const type of types) {
const sumDir = path.join(memDir, "summaries", type);
let files;
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { continue; }
if (files.length === 0) continue;
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
for (const f of files.slice(0, 10)) {
const name = f.replace(".md", "");
const content = readFile(path.join(sumDir, f));
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
const sessions = sessMatch ? sessMatch[1] : "?";
out += ` ${name} ${sessions} session(s)\n`;
}
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
}
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
return out.trimEnd();
}
```
- [ ] **Step 3: Wire `tags` and `summary` into the command switch**
In the switch statement, add before the `help` case:
```javascript
case "tags": text = cmdTags(subargs || null); break;
case "summary": text = cmdSummary(subargs || null); break;
```
- [ ] **Step 4: Update `cmdHelp` to include new commands**
Add these lines after `/mc sessions [date]`:
```
/mc summary [daily|weekly] List or view summaries
/mc tags [agent] View tag index
```
- [ ] **Step 5: Verify no syntax errors**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
```
- [ ] **Step 6: Commit**
```bash
git add mc-plugin/index.js
git commit -m "feat: add /mc tags and /mc summary commands (v3.2)"
```
---
### Task 6: Version Bump and Final Verification
**Files:**
- Modify: `openclaw.plugin.json` (version 3.1.0 → 3.2.0)
- Modify: `package.json` (version 3.1.0 → 3.2.0)
- [ ] **Step 1: Bump version in both files to 3.2.0**
Change `"version": "3.1.0"` to `"version": "3.2.0"` in both `openclaw.plugin.json` and `package.json`.
- [ ] **Step 2: Verify all files parse correctly**
Run:
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
```
Expected: `All OK`
- [ ] **Step 3: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "release: v3.2.0 — mid-term memory layer"
```
- [ ] **Step 4: Push to GitHub**
```bash
git push
```
---
## Summary of New Features
| Feature | Files | Description |
|---------|-------|-------------|
| Daily summaries | `memory/summaries/daily/YYYY-MM-DD.md` | Auto-generated from previous day's session log |
| Weekly summaries | `memory/summaries/weekly/YYYY-Www.md` | Rolls up daily summaries every Monday |
| Auto-tagging | `memory/tags.md` | Extracts `#tag` from session topics, maintains index |
| Enhanced search | `/mc search` | Now covers state + archives + sessions + summaries, grouped by type |
| `/mc summary` | mc-plugin | List/view daily and weekly summaries |
| `/mc tags` | mc-plugin | View tag index with counts and latest dates |
## New Config Keys
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `summaryEnabled` | `boolean` | `true` | Enable/disable summary generation |
## New Commands
| Command | Purpose |
|---------|---------|
| `/mc summary [daily\|weekly]` | List summaries or view specific one |
| `/mc summary <date>` | View specific daily/weekly summary |
| `/mc tags [agent]` | View tag index |
@@ -0,0 +1,516 @@
# Phase 4 — Ecosystem Integration (v4.0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Package Memory Continuity for OpenClaw marketplace publishing, declare complementary positioning with lossless-claw, and expose a programmatic skill interface so other plugins can call `/mc recall`.
**Architecture:** Three independent additions — (1) marketplace metadata in manifests + README update, (2) interop declaration via `openclaw.plugin.json` `interop` field and docs, (3) a new `api.exposeService()` call in the lifecycle plugin that other plugins can query for recall results without going through the slash command.
**Tech Stack:** Node.js ESM, OpenClaw plugin API (`api.exposeService`, `api.registerCommand`), markdown files, zero external deps.
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `openclaw.plugin.json` | Add marketplace metadata fields (author, license, icon, category, interop) |
| Modify | `package.json` | Bump to 4.0.0, add marketplace fields |
| Modify | `mc-plugin/package.json` | Bump to 2.0.0 |
| Modify | `mc-plugin/openclaw.plugin.json` | Bump to 2.0.0, add marketplace category |
| Modify | `index.js` | Add `api.exposeService("mc:recall", ...)` in `register()` |
| Modify | `README.md` | Update version, add marketplace install section, interop docs, skill API docs |
| Create | `MARKETPLACE.md` | Marketplace listing long description (separate from README for marketplace scraping) |
---
### Task 1: Marketplace Metadata for Lifecycle Plugin
**Files:**
- Modify: `openclaw.plugin.json`
- Modify: `package.json`
The OpenClaw `plugins install` command supports `--marketplace <source>` and `openclaw plugins marketplace list <source>`. The manifest needs enriched metadata for discoverability.
- [ ] **Step 1: Add marketplace fields to `openclaw.plugin.json`**
Open `openclaw.plugin.json` and add the following fields alongside the existing content:
```json
{
"id": "memory-continuity",
"name": "Memory Continuity",
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
"version": "4.0.0",
"author": {
"name": "dtzp555-max",
"url": "https://github.com/dtzp555-max"
},
"license": "MIT",
"repository": "https://github.com/dtzp555-max/memory-continuity",
"category": "memory",
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
"icon": "brain",
"minOpenClawVersion": "2026.3.0",
"source": "https://github.com/dtzp555-max/memory-continuity",
"configSchema": { ... existing configSchema unchanged ... },
"uiHints": { ... existing uiHints unchanged ... }
}
```
Key additions:
- `author` — object with name and URL
- `license` — MIT
- `category` — "memory" (standard marketplace category)
- `tags` — array for search/filter
- `icon` — emoji-name for UI display
- `minOpenClawVersion` — minimum compatible version
- [ ] **Step 2: Update `package.json` to v4.0.0**
```json
{
"name": "memory-continuity",
"version": "4.0.0",
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git"
},
"keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max",
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "memory-continuity",
"pluginManifest": "openclaw.plugin.json"
}
}
```
- [ ] **Step 3: Verify plugin still loads**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 4: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "feat(marketplace): add marketplace metadata to lifecycle plugin manifest"
```
---
### Task 2: Marketplace Metadata for MC Commands Plugin
**Files:**
- Modify: `mc-plugin/openclaw.plugin.json`
- Modify: `mc-plugin/package.json`
- [ ] **Step 1: Add marketplace fields to `mc-plugin/openclaw.plugin.json`**
```json
{
"id": "mc",
"name": "Memory Continuity Commands",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc recall, /mc settings, etc.",
"version": "2.0.0",
"author": {
"name": "dtzp555-max",
"url": "https://github.com/dtzp555-max"
},
"license": "MIT",
"category": "commands",
"tags": ["memory", "commands", "search", "recall"],
"icon": "terminal",
"requires": ["memory-continuity"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"memoryDir": {
"type": "string",
"default": "",
"description": "Override memory directory path (auto-detected from workspace if empty)"
}
}
}
}
```
Key addition: `requires` — declares dependency on the `memory-continuity` lifecycle plugin.
- [ ] **Step 2: Bump `mc-plugin/package.json` to 2.0.0**
```json
{
"name": "mc-plugin",
"version": "2.0.0",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "mc",
"pluginManifest": "openclaw.plugin.json"
}
}
```
- [ ] **Step 3: Verify mc-plugin still loads**
```bash
cd ~/.openclaw/projects/memory-continuity/mc-plugin
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: function`
- [ ] **Step 4: Commit**
```bash
git add mc-plugin/openclaw.plugin.json mc-plugin/package.json
git commit -m "feat(marketplace): add marketplace metadata to mc-plugin manifest"
```
---
### Task 3: lossless-claw Interop Declaration
**Files:**
- Modify: `openclaw.plugin.json` (add `interop` field)
- Create: `MARKETPLACE.md`
The key design point: MC uses lifecycle hooks (not the `contextEngine` slot), so it coexists with lossless-claw. MC handles working-state recovery; lossless-claw handles full context compression. They complement each other.
- [ ] **Step 1: Add `interop` field to `openclaw.plugin.json`**
Add the following field to the root of the manifest (after `minOpenClawVersion`):
```json
"interop": {
"complements": [
{
"id": "lossless-claw",
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
}
],
"conflicts": [],
"slot": "hooks-only",
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
}
```
- [ ] **Step 2: Create `MARKETPLACE.md`**
This file provides the long-form marketplace listing description (separate from README which is for developers).
```markdown
# Memory Continuity — Marketplace Listing
## What it does
Memory Continuity is a **zero-dependency lifecycle plugin** that preserves your working state across session resets, compaction, and gateway restarts. It answers one question:
> What were we doing, where did we stop, and what should happen next?
## Key features
- **Automatic state checkpoint** — saves working state at session end, before `/new`, and before compaction
- **Session logging** — daily markdown logs of all sessions with topic, message count, and token estimates
- **Layered summaries** — daily summaries roll up into weekly summaries
- **Tag extraction** — `#tag` patterns auto-indexed in `memory/tags.md`
- **Relevance injection** — injects related history at session start based on keyword matching
- **Memory decay** — old archives move to cold storage after configurable days
- **CJK-aware** — proper token estimation for Chinese/Japanese/Korean text
- **Search & recall** — `/mc search` and `/mc recall` for cross-memory search
## Why this plugin?
| Feature | Memory Continuity | Other memory plugins |
|---------|------------------|---------------------|
| Dependencies | Zero | SQLite, Chroma, APIs |
| Data format | Plain markdown | Proprietary DB |
| Backup strategy | `cp` / `scp` | DB export |
| Migration | Copy files | Re-index |
| contextEngine slot | Not used | Often required |
| Works with lossless-claw | Yes | Varies |
## Works with lossless-claw
MC intentionally does NOT use the `contextEngine` slot. It runs via lifecycle hooks only. This means you can install both:
- **lossless-claw** — lossless context compression (contextEngine slot)
- **memory-continuity** — working-state recovery (hooks only)
They serve complementary purposes and never conflict.
## Install
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
## Configuration
All settings are optional with sensible defaults. See `openclaw plugins inspect memory-continuity` for the full config schema.
```
- [ ] **Step 3: Commit**
```bash
git add openclaw.plugin.json MARKETPLACE.md
git commit -m "feat(interop): declare lossless-claw complementary positioning + marketplace listing"
```
---
### Task 4: Programmatic Skill Interface — `mc:recall` Service
**Files:**
- Modify: `index.js` (add `api.exposeService` in `register()`)
The goal: other plugins can programmatically call `api.getService("mc:recall")` to get recall results as structured data, without parsing slash command output.
- [ ] **Step 1: Add the `findRelevantHistory` re-export as a service**
At the end of the `register(api)` function in `index.js`, before the final log line, add:
```javascript
// ------------------------------------------------------------------
// SERVICE: mc:recall — programmatic interface for other plugins
// ------------------------------------------------------------------
if (typeof api.exposeService === "function") {
api.exposeService("mc:recall", {
description: "Search memory history by topic keywords. Returns scored results.",
/**
* @param {Object} params
* @param {string} params.topic - Keywords to search for
* @param {number} [params.maxItems=5] - Maximum results to return
* @param {string} [params.format="structured"] - "structured" returns array of objects, "text" returns formatted string
* @returns {{ results: Array<{ date: string, type: string, score: number, summary: string }>, total: number }}
*/
async handler(params, ctx) {
const topic = params?.topic;
if (!topic || typeof topic !== "string") {
return { error: "topic (string) is required", results: [], total: 0 };
}
const ws = ctx?.workspaceDir;
if (!ws) {
return { error: "no workspace context", results: [], total: 0 };
}
const maxItems = Math.min(params?.maxItems ?? 5, 20);
// Reuse the internal findRelevantHistory function
const textResult = findRelevantHistory(ws, topic, maxItems);
if (!textResult) {
return { results: [], total: 0 };
}
// For "text" format, return the raw string (same as before_agent_start injection)
if (params?.format === "text") {
return { text: textResult, total: 1 };
}
// For "structured" format, parse the result into objects
const lines = textResult.split("\n").filter(l => l.startsWith("["));
const results = lines.map(line => {
const dateMatch = line.match(/^\[([^\]]+)\]/);
const summary = line.replace(/^\[[^\]]+\]\s*/, "").trim();
return {
date: dateMatch?.[1] || "unknown",
type: "history",
score: 0,
summary,
};
});
return { results, total: results.length };
},
});
log.info?.("[memory-continuity] Exposed mc:recall service for inter-plugin use");
}
```
- [ ] **Step 2: Verify plugin still loads with the service**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default, typeof m.default.register))"
```
Expected: `OK: object function`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(skill-api): expose mc:recall as programmatic service for inter-plugin use"
```
---
### Task 5: README Update
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update README header and install section**
Update the version line at the top:
```markdown
**Current release:** `v4.0.0`
```
Add after the existing "Quick Start" / "Install" section:
```markdown
### Marketplace Install (recommended)
```bash
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
```
### Works with lossless-claw
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
- **lossless-claw** = lossless context compression (contextEngine slot)
- **memory-continuity** = working-state recovery (hooks only)
Install both for the best experience.
### Programmatic API
Other plugins can call MC's recall function programmatically:
```javascript
// In another plugin's register() function:
const recall = api.getService("mc:recall");
if (recall) {
const result = await recall.handler(
{ topic: "deployment issues", maxItems: 3 },
ctx
);
// result.results = [{ date, type, score, summary }, ...]
}
```
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: update README with marketplace install, interop, and skill API sections"
```
---
### Task 6: Final Version Bump + Verification
**Files:**
- Verify: all modified files
- [ ] **Step 1: Verify all version numbers match**
```bash
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
```
Expected:
- `openclaw.plugin.json``"4.0.0"`
- `package.json``"4.0.0"`
- `mc-plugin/openclaw.plugin.json``"2.0.0"`
- `mc-plugin/package.json``"2.0.0"`
- [ ] **Step 2: Verify both plugins load**
```bash
cd ~/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('lifecycle:', typeof m.default))"
node -e "import('./mc-plugin/index.js').then(m => console.log('mc-plugin:', typeof m.default))"
```
Expected:
```
lifecycle: object
mc-plugin: function
```
- [ ] **Step 3: Copy to extensions and restart gateway**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
cp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json ~/.openclaw/extensions/mc/ 2>/dev/null
```
- [ ] **Step 4: Verify with `openclaw plugins inspect`**
```bash
openclaw plugins inspect memory-continuity
```
Expected: shows `Version: 4.0.0` and all 5 hooks.
- [ ] **Step 5: Final commit + tag**
```bash
git add -A
git commit -m "release: v4.0.0 — ecosystem integration (marketplace, interop, skill API)"
git tag v4.0.0
git push origin main --tags
```
---
### Task 7: Deploy to All 3 Servers
**Files:** None (deployment only)
- [ ] **Step 1: Deploy to Cloud (152.67.121.35)**
```bash
scp index.js package.json openclaw.plugin.json MARKETPLACE.md opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 2: Deploy to PI (172.16.2.232)**
```bash
scp index.js package.json openclaw.plugin.json MARKETPLACE.md administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 3: Restart Mac gateway**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &
```
- [ ] **Step 4: Verify all 3 servers**
```bash
grep version ~/.openclaw/extensions/memory-continuity/package.json
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
```
Expected: all show `"version": "4.0.0"`.
@@ -0,0 +1,556 @@
# Phase 5 — Conservative Subagent Support (v5.0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable working-state continuity across parent/child agent boundaries — parent seeds child with structured context, child recovers unsurfaced results back to parent.
**Architecture:** Two new lifecycle hooks added to the existing plugin. (1) Enhance `before_agent_start` to detect forked child sessions and inject the parent's CURRENT_STATE.md as a structured seed. (2) Add a new `subagent_ended` hook that reads the child's workspace CURRENT_STATE.md and merges unsurfaced results into the parent's state. Both features are conservative — they degrade gracefully when context is unavailable and never overwrite parent state destructively.
**Tech Stack:** Node.js ESM, OpenClaw plugin lifecycle hooks (`before_agent_start`, `subagent_ended`), filesystem-based state passing (cross-workspace read), zero external dependencies.
**Research Findings (constraints):**
- `subagent_ended` event provides: `{ targetSessionKey, reason, outcome, error }` and context `{ childSessionKey, requesterSessionKey }`
- No pre-spawn hook exists — parent seeding happens via `before_agent_start` on the child side
- Workspaces are fully isolated — each agent has its own `memory/` directory
- Session key format: `agent:<agentId>:subagent:<uuid>` — parseable to extract `agentId`
- Agent workspace is resolvable via config: `agents.list[].workspace` or default `~/.openclaw/workspace/main`
- `isSubagentSessionKey()` and `parseAgentSessionKey()` are runtime utilities (not available to plugins directly — must parse manually)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `index.js` | Add parent-seed logic to `before_agent_start`, add new `subagent_ended` hook, add helper functions |
| Modify | `openclaw.plugin.json` | Bump to 5.0.0, add new config keys |
| Modify | `package.json` | Bump to 5.0.0 |
---
### Task 1: Add Workspace Resolution Helpers
**Files:**
- Modify: `index.js` (helpers section, before plugin definition)
Two new helper functions are needed: one to detect if the current session is a subagent, and one to resolve a workspace directory from an agent ID.
- [ ] **Step 1: Add `isSubagentSession` helper**
In `index.js`, after the existing `extractStateFromMessages` function (around line 709) and before the `// Plugin Definition` comment, add:
```javascript
/**
* Detect if the current session is a subagent by checking the session key format.
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
*/
function isSubagentSession(ctx) {
const key = ctx?.sessionKey || ctx?.SessionKey || "";
return /^agent:[^:]+:subagent:/.test(key);
}
/**
* Extract the parent agent ID from a subagent session key.
* Session key format: agent:<parentAgentId>:subagent:<uuid>
* Returns null if not a subagent key.
*/
function parseParentAgentId(sessionKey) {
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
return match?.[1] || null;
}
/**
* Resolve workspace directory for a given agent ID.
* Searches the OpenClaw config for agent workspace mappings.
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
* or ~/.openclaw/workspaces/<agentId> for named agents.
*/
function resolveAgentWorkspace(agentId) {
if (!agentId) return null;
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
// Try to read config for explicit workspace mapping
try {
const configPath = path.join(base, "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const agents = config?.agents?.list || [];
const entry = agents.find(a => a.id === agentId || a.name === agentId);
if (entry?.workspace) return entry.workspace;
// Check defaults
if (agentId === "main") {
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
}
} catch {}
// Fallback heuristics
if (agentId === "main") return path.join(base, "workspace", "main");
// Named agents typically use ~/.openclaw/workspaces/<agentId>
const namedWs = path.join(base, "workspaces", agentId);
if (fs.existsSync(namedWs)) return namedWs;
return null;
}
/**
* Extract the Unsurfaced Results section from a state markdown file.
* Returns the raw text content of the section, or null if empty/placeholder.
*/
function extractUnsurfacedResults(md) {
if (!md) return null;
const section = extractSection(md, "Unsurfaced Results");
if (!section || !isMeaningful(section)) return null;
return section;
}
```
- [ ] **Step 2: Verify the file still parses**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): add workspace resolution and session detection helpers"
```
---
### Task 2: Parent→Child Seeding in before_agent_start
**Files:**
- Modify: `index.js` (HOOK 1: before_agent_start)
When a child agent starts (detected via subagent session key format), read the parent agent's CURRENT_STATE.md and inject it as additional context alongside the child's own state.
- [ ] **Step 1: Enhance the `before_agent_start` hook**
In `index.js`, find HOOK 1 (`before_agent_start`). The current code reads the agent's own state and injects it. After the existing relevance injection block (around the `if (config.relevanceInjection !== false)` block), add parent-seed logic:
Find this existing code block:
```javascript
// Relevance injection: find related historical entries
if (config.relevanceInjection !== false) {
const objective = extractSection(md, "Objective");
const maxItems = config.maxRelevanceItems ?? 3;
const history = findRelevantHistory(ws, objective, maxItems);
if (history) {
parts.push(history);
log.info?.("[memory-continuity] Injected relevant history context");
}
}
```
Right after this block (before the `return` statement), insert:
```javascript
// Parent seed: if this is a subagent, inject parent's working state
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
try {
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
const parentAgentId = parseParentAgentId(sessionKey);
if (parentAgentId) {
const parentWs = resolveAgentWorkspace(parentAgentId);
if (parentWs) {
const parentStatePath = resolveStatePath(parentWs);
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
if (parentMd) {
const parentSnapshot = buildSnapshot(parentMd);
if (parentSnapshot) {
parts.push(
"=== PARENT AGENT CONTEXT ===\n" +
"The following is the parent agent's working state. " +
"Use this to understand the broader task context.\n" +
parentSnapshot +
"\n=== END PARENT CONTEXT ==="
);
log.info?.("[memory-continuity] Injected parent state seed for subagent");
}
}
}
}
} catch (err) {
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
}
}
```
- [ ] **Step 2: Verify plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): parent-to-child state seeding in before_agent_start"
```
---
### Task 3: Child→Parent Recovery via subagent_ended Hook
**Files:**
- Modify: `index.js` (add new HOOK 6: subagent_ended)
When a child agent ends, read its CURRENT_STATE.md and check for unsurfaced results. If found, append them to the parent's state file.
- [ ] **Step 1: Add the `subagent_ended` hook**
In `index.js`, find the section after HOOK 5 (`session_end`) and before the SERVICE section. Add a new hook between them:
```javascript
// ------------------------------------------------------------------
// HOOK 6: subagent_ended — recover child's unsurfaced results
// ------------------------------------------------------------------
api.on("subagent_ended", async (event, _ctx) => {
const config = getConfig();
if (config.subagentRecovery === false) return;
const parentWs = _ctx?.workspaceDir;
if (!parentWs) return;
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
if (!childSessionKey) {
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
return;
}
// Only process successful completions (not kills/errors)
const outcome = event?.outcome || "";
const reason = event?.reason || "";
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
return;
}
try {
// Parse child agent ID from session key
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
const childAgentId = childMatch?.[1];
if (!childAgentId) return;
const childWs = resolveAgentWorkspace(childAgentId);
if (!childWs) {
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
return;
}
// Read child's state
const childStatePath = resolveStatePath(childWs);
const childMd = childStatePath ? readFile(childStatePath) : null;
if (!childMd) return;
// Extract unsurfaced results from child
const childUnsurfaced = extractUnsurfacedResults(childMd);
if (!childUnsurfaced) {
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
return;
}
// Also extract child's objective for context
const childObjective = extractSection(childMd, "Objective");
// Read parent's current state
const parentStatePath = resolveStatePath(parentWs);
if (!parentStatePath) return;
let parentMd = readFile(parentStatePath);
if (!parentMd) {
parentMd = STATE_TEMPLATE;
}
// Build the recovery note
const now = new Date().toISOString();
const recoveryNote = [
`[${now}] Subagent "${childAgentId}" completed.`,
childObjective ? ` Task: ${childObjective.split("\n")[0].slice(0, 120)}` : "",
` Result: ${childUnsurfaced.split("\n")[0].slice(0, 200)}`,
].filter(Boolean).join("\n");
// Merge into parent's Unsurfaced Results section
const existingUnsurfaced = extractSection(parentMd, "Unsurfaced Results");
const mergedUnsurfaced = isMeaningful(existingUnsurfaced)
? existingUnsurfaced + "\n" + recoveryNote
: recoveryNote;
// Token-aware truncation of merged results
if (estimateTokens(mergedUnsurfaced) > 500) {
// Keep only the most recent entries (last 500 tokens)
const lines = mergedUnsurfaced.split("\n");
let kept = [];
let tokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const lineTokens = estimateTokens(lines[i]);
if (tokens + lineTokens > 500) break;
kept.unshift(lines[i]);
tokens += lineTokens;
}
const truncated = kept.join("\n");
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + truncated
);
} else {
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + mergedUnsurfaced
);
}
// Update the timestamp
parentMd = parentMd.replace(
/^> Last updated:.*$/m,
`> Last updated: ${now}`
);
writeFile(parentStatePath, parentMd);
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
} catch (err) {
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
}
}, { priority: 50 });
```
- [ ] **Step 2: Verify plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: object`
- [ ] **Step 3: Commit**
```bash
git add index.js
git commit -m "feat(subagent): child-to-parent unsurfaced results recovery via subagent_ended"
```
---
### Task 4: New Config Keys + Manifest Bump
**Files:**
- Modify: `openclaw.plugin.json`
- Modify: `package.json`
- [ ] **Step 1: Add subagent config keys to `openclaw.plugin.json`**
Bump version to `5.0.0`. Add two new config properties inside `configSchema.properties`:
```json
"subagentSeed": {
"type": "boolean",
"default": true,
"description": "Inject parent working state into subagent context at startup"
},
"subagentRecovery": {
"type": "boolean",
"default": true,
"description": "Recover unsurfaced results from completed subagents back to parent"
}
```
Add corresponding `uiHints`:
```json
"subagentSeed": {
"label": "Subagent seeding",
"help": "Give subagents parent context so they understand the broader task"
},
"subagentRecovery": {
"label": "Subagent recovery",
"help": "Pull completed subagent results back into parent state"
}
```
- [ ] **Step 2: Bump `package.json` to 5.0.0**
Change `"version": "4.0.0"` to `"version": "5.0.0"`.
- [ ] **Step 3: Verify JSON validity**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity
python3 -m json.tool openclaw.plugin.json > /dev/null && echo "JSON valid"
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected:
```
JSON valid
OK: object
```
- [ ] **Step 4: Commit**
```bash
git add openclaw.plugin.json package.json
git commit -m "feat(subagent): add subagentSeed and subagentRecovery config keys, bump to v5.0.0"
```
---
### Task 5: Add `/mc subagents` Command
**Files:**
- Modify: `mc-plugin/index.js`
- Modify: `mc-plugin/package.json`
- Modify: `mc-plugin/openclaw.plugin.json`
Add a new `/mc subagents` command that shows subagent state across workspaces.
- [ ] **Step 1: Add `cmdSubagents` function to mc-plugin**
In `mc-plugin/index.js`, before the `cmdHelp` function, add:
```javascript
function cmdSubagents(args) {
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
let out = "Subagent State Overview\n";
out += "═══════════════════════\n\n";
for (const { name, memDir } of agents) {
const statePath = path.join(memDir, "CURRENT_STATE.md");
const content = readFile(statePath);
if (!content) {
out += `[${name}] No state file\n\n`;
continue;
}
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
const objective = objMatch?.[1]?.trim() || "None";
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
const updated = updatedMatch?.[1]?.trim() || "unknown";
out += `[${name}] Updated: ${updated}\n`;
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
if (unsurfaced !== "None") {
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
}
out += "\n";
}
return out.trimEnd();
}
```
- [ ] **Step 2: Register the command in the switch statement**
Find the `switch (subcmd)` block and add a new case before the `help` case:
```javascript
case "subagents": text = cmdSubagents(subargs || null); break;
```
- [ ] **Step 3: Update help text**
In `cmdHelp`, add:
```
/mc subagents Subagent state overview across workspaces
```
- [ ] **Step 4: Bump mc-plugin versions to 2.1.0**
In `mc-plugin/package.json`, change version to `"2.1.0"`.
In `mc-plugin/openclaw.plugin.json`, change version to `"2.1.0"`.
- [ ] **Step 5: Verify mc-plugin loads**
```bash
cd /Users/taodeng/.openclaw/projects/memory-continuity/mc-plugin
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
```
Expected: `OK: function`
- [ ] **Step 6: Commit**
```bash
git add mc-plugin/
git commit -m "feat(subagent): add /mc subagents command for cross-workspace state view"
```
---
### Task 6: Version Verify + Git + Deploy
**Files:** All modified files
- [ ] **Step 1: Verify all versions**
```bash
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
```
Expected:
```
openclaw.plugin.json: "version": "5.0.0"
package.json: "version": "5.0.0"
mc-plugin/openclaw.plugin.json: "version": "2.1.0"
mc-plugin/package.json: "version": "2.1.0"
```
- [ ] **Step 2: Final commit + tag + push**
```bash
git add -A
git commit -m "release: v5.0.0 — conservative subagent support (parent seed + child recovery)"
git tag v5.0.0
git push origin main --tags
```
- [ ] **Step 3: Deploy to Cloud**
```bash
scp index.js package.json openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 4: Deploy to PI**
```bash
scp index.js package.json openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
```
- [ ] **Step 5: Deploy to Mac**
```bash
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &
```
- [ ] **Step 6: Verify all 3 servers**
```bash
grep version ~/.openclaw/extensions/memory-continuity/package.json
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
```
Expected: all show `"version": "5.0.0"`.
+843 -10
View File
@@ -14,7 +14,39 @@ const PLACEHOLDER_VALUES = new Set([
"[exactly what should happen next]", "[exactly what should happen next]",
]); ]);
const STATE_TEMPLATE = `# Current State // 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);
}
function stateTemplate() {
return `# Current State
> Last updated: ${new Date().toISOString()} > Last updated: ${new Date().toISOString()}
## Objective ## Objective
@@ -35,6 +67,7 @@ None
## Unsurfaced Results ## Unsurfaced Results
None None
`; `;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -139,6 +172,473 @@ 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);
// Correct ISO 8601 week number
const target = new Date(prevMonday.valueOf());
target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7));
const jan4 = new Date(target.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((target - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 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 exact tag+date combo already exists under this tag's section
const tagSection = existing.match(new RegExp(`## ${normalizedTag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n([\\s\\S]*?)(?=\\n## |$)`));
if (tagSection && tagSection[1].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 +671,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()}
@@ -197,6 +713,74 @@ ${truncate(lastAssistant, 500)}
`; `;
} }
/**
* Detect if the current session is a subagent by checking the session key format.
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
*/
function isSubagentSession(ctx) {
const key = ctx?.sessionKey || ctx?.SessionKey || "";
return /^agent:[^:]+:subagent:/.test(key);
}
/**
* Extract the parent agent ID from a subagent session key.
* Session key format: agent:<parentAgentId>:subagent:<uuid>
* Returns null if not a subagent key.
*/
function parseParentAgentId(sessionKey) {
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
return match?.[1] || null;
}
/**
* Resolve workspace directory for a given agent ID.
* Searches the OpenClaw config for agent workspace mappings.
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
* or ~/.openclaw/workspaces/<agentId> for named agents.
*/
function resolveAgentWorkspace(agentId) {
if (!agentId) return null;
// Prevent path traversal via crafted agent IDs
if (!/^[\w-]+$/.test(agentId)) return null;
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
// Try to read config for explicit workspace mapping
try {
const configPath = path.join(base, "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const agents = config?.agents?.list || [];
const entry = agents.find(a => a.id === agentId || a.name === agentId);
if (entry?.workspace) return entry.workspace;
// Check defaults
if (agentId === "main") {
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
}
} catch {}
// Fallback heuristics
if (agentId === "main") return path.join(base, "workspace", "main");
// Named agents typically use ~/.openclaw/workspaces/<agentId>
const namedWs = path.join(base, "workspaces", agentId);
if (fs.existsSync(namedWs)) return namedWs;
return null;
}
/**
* Extract the Unsurfaced Results section from a state markdown file.
* Returns the raw text content of the section, or null if empty/placeholder.
*/
function extractUnsurfacedResults(md) {
if (!md) return null;
const section = extractSection(md, "Unsurfaced Results");
if (!section || !isMeaningful(section)) return null;
return section;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin Definition // Plugin Definition
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -214,6 +798,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 +810,52 @@ 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");
}
}
// Parent seed: if this is a subagent, inject parent's working state
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
try {
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
const parentAgentId = parseParentAgentId(sessionKey);
if (parentAgentId) {
const parentWs = resolveAgentWorkspace(parentAgentId);
if (parentWs) {
const parentStatePath = resolveStatePath(parentWs);
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
if (parentMd) {
const parentSnapshot = buildSnapshot(parentMd);
if (parentSnapshot) {
parts.push(
"=== PARENT AGENT CONTEXT ===\n" +
"The following is the parent agent's working state. " +
"Use this to understand the broader task context.\n" +
parentSnapshot +
"\n=== END PARENT CONTEXT ==="
);
log.info?.("[memory-continuity] Injected parent state seed for subagent");
}
}
}
}
} catch (err) {
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
}
}
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 +867,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 +879,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 +933,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 +944,31 @@ 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 || [])
.filter(p => typeof p === "string" && p.length <= 100)
.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);
@@ -331,11 +995,180 @@ const plugin = {
if (!statePath) return; if (!statePath) return;
if (!readFile(statePath)) { if (!readFile(statePath)) {
writeFile(statePath, STATE_TEMPLATE); writeFile(statePath, stateTemplate());
log.info?.("[memory-continuity] Created initial CURRENT_STATE.md"); log.info?.("[memory-continuity] Created initial CURRENT_STATE.md");
} }
}, { priority: 90 }); }, { priority: 90 });
// ------------------------------------------------------------------
// HOOK 6: subagent_ended — recover child's unsurfaced results
// ------------------------------------------------------------------
api.on("subagent_ended", async (event, _ctx) => {
const config = getConfig();
if (config.subagentRecovery === false) return;
const parentWs = _ctx?.workspaceDir;
if (!parentWs) return;
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
if (!childSessionKey) {
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
return;
}
// Only process successful completions (not kills/errors)
const outcome = event?.outcome || "";
const reason = event?.reason || "";
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
return;
}
try {
// Parse child agent ID from session key
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
const childAgentId = childMatch?.[1];
if (!childAgentId) return;
const childWs = resolveAgentWorkspace(childAgentId);
if (!childWs) {
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
return;
}
// Read child's state
const childStatePath = resolveStatePath(childWs);
const childMd = childStatePath ? readFile(childStatePath) : null;
if (!childMd) return;
// Extract unsurfaced results from child
const childUnsurfaced = extractUnsurfacedResults(childMd);
if (!childUnsurfaced) {
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
return;
}
// Also extract child's objective for context
const childObjective = extractSection(childMd, "Objective");
// Read parent's current state
const parentStatePath = resolveStatePath(parentWs);
if (!parentStatePath) return;
let parentMd = readFile(parentStatePath);
if (!parentMd) {
parentMd = stateTemplate();
}
// Build the recovery note
const now = new Date().toISOString();
const recoveryNote = [
`[${now}] Subagent "${childAgentId}" completed.`,
childObjective ? ` Task: ${childObjective.split("\n")[0].slice(0, 120)}` : "",
` Result: ${childUnsurfaced.split("\n")[0].slice(0, 200)}`,
].filter(Boolean).join("\n");
// Merge into parent's Unsurfaced Results section
const existingUnsurfaced = extractSection(parentMd, "Unsurfaced Results");
const mergedUnsurfaced = isMeaningful(existingUnsurfaced)
? existingUnsurfaced + "\n" + recoveryNote
: recoveryNote;
// Token-aware truncation of merged results
if (estimateTokens(mergedUnsurfaced) > 500) {
const lines = mergedUnsurfaced.split("\n");
let kept = [];
let tokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const lineTokens = estimateTokens(lines[i]);
if (tokens + lineTokens > 500) break;
kept.unshift(lines[i]);
tokens += lineTokens;
}
const truncated = kept.join("\n");
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + truncated
);
} else {
parentMd = parentMd.replace(
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
"## Unsurfaced Results\n" + mergedUnsurfaced
);
}
// Update the timestamp
parentMd = parentMd.replace(
/^> Last updated:.*$/m,
`> Last updated: ${now}`
);
writeFile(parentStatePath, parentMd);
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
} catch (err) {
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
}
}, { priority: 50 });
// ------------------------------------------------------------------
// 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");
}, },
}; };
+883
View File
@@ -0,0 +1,883 @@
/**
* MC Plugin — registers /mc as a native slash command in OpenClaw gateway.
* Provides interactive commands for the Memory Continuity plugin.
*/
import fs from "node:fs";
import path from "node:path";
// ── Helpers ─────────────────────────────────────────────────────────────
function mono(text) { return "```\n" + text + "\n```"; }
const BASE = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
const MAIN_WS = path.join(BASE, "workspace", "main", "memory");
const EXTRA_WS = path.join(BASE, "workspaces");
/** Discover all agent workspaces that have memory directories */
function discoverAgents() {
const agents = [];
// Main workspace
if (fs.existsSync(path.join(MAIN_WS, "CURRENT_STATE.md"))) {
agents.push({ name: "main", memDir: MAIN_WS });
}
// Sub-agent workspaces
try {
for (const d of fs.readdirSync(EXTRA_WS)) {
if (!/^[\w-]+$/.test(d)) continue;
const memDir = path.join(EXTRA_WS, d, "memory");
if (fs.existsSync(path.join(memDir, "CURRENT_STATE.md"))) {
agents.push({ name: d, memDir });
}
}
} catch {}
return agents;
}
function resolveMemDir(agentName) {
if (!agentName || agentName === "main") return MAIN_WS;
const p = path.join(EXTRA_WS, agentName, "memory");
return fs.existsSync(p) ? p : null;
}
function readFile(fp) {
try { return fs.readFileSync(fp, "utf8"); } catch { return null; }
}
function extractSection(md, heading) {
const re = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "m");
return (md.match(re)?.[1] ?? "").trim();
}
function relTime(dateStr) {
try {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m ago`;
return `${Math.floor(hrs / 24)}d ago`;
} catch { return dateStr || "unknown"; }
}
function truncate(s, max = 120) {
if (!s) return "(empty)";
const line = s.split("\n")[0];
return line.length > max ? line.slice(0, max) + "…" : line;
}
// ── Subcommand handlers ─────────────────────────────────────────────────
function cmdState(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const md = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (!md) return `No state file for "${agent}".`;
const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim();
let out = `State: ${agent}\n`;
out += `Updated: ${relTime(updated)}\n`;
out += "─────────────────────────────\n";
for (const section of ["Objective", "Current Step", "Key Decisions", "Next Action", "Blockers"]) {
const val = extractSection(md, section);
// Compact multi-line values
const compact = val.split("\n").slice(0, 3).join("\n");
out += `${section}:\n ${compact || "(empty)"}\n\n`;
}
return out.trimEnd();
}
function cmdStateAll() {
const agents = discoverAgents();
if (!agents.length) return "No agents with memory found.";
let out = `Memory States (${agents.length} agents)\n`;
out += "─────────────────────────────\n";
for (const { name, memDir } of agents) {
const md = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (!md) { out += `${name.padEnd(20)} (no state)\n`; continue; }
const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim();
const objective = extractSection(md, "Objective");
out += `${name.padEnd(20)} ${relTime(updated).padEnd(10)} ${truncate(objective, 50)}\n`;
}
return out.trimEnd();
}
function cmdHistory(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const archiveDir = path.join(memDir, "session_archive");
let files;
try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No archive for "${agent}".`; }
if (!files.length) return `No archived sessions for "${agent}".`;
let out = `Session Archive: ${agent} (${files.length} entries)\n`;
out += "─────────────────────────────\n";
out += `${"#".padStart(3)} ${"Timestamp".padEnd(20)} Objective\n`;
out += "─".repeat(60) + "\n";
for (let i = 0; i < Math.min(files.length, 20); i++) {
const f = files[i];
const ts = f.replace(".md", "").replace(/_/g, " ");
const md = readFile(path.join(archiveDir, f));
const obj = md ? truncate(extractSection(md, "Objective"), 35) : "?";
out += `${String(i + 1).padStart(3)} ${ts.padEnd(20)} ${obj}\n`;
}
if (files.length > 20) out += `\n ... and ${files.length - 20} more`;
return out.trimEnd();
}
function cmdRestore(args) {
const parts = (args || "").trim().split(/\s+/);
const idx = parseInt(parts[0]);
const agent = parts[1] || "main";
if (isNaN(idx) || idx < 1) return "Usage: /mc restore <N> [agent]\nN = archive number from /mc history";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const archiveDir = path.join(memDir, "session_archive");
let files;
try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No archive for "${agent}".`; }
if (idx > files.length) return `Only ${files.length} archives available.`;
const target = files[idx - 1];
const content = readFile(path.join(archiveDir, target));
if (!content) return `Failed to read archive ${target}.`;
// Backup current state first
const statePath = path.join(memDir, "CURRENT_STATE.md");
const current = readFile(statePath);
if (current) {
const bak = path.join(memDir, "CURRENT_STATE.md.bak");
fs.writeFileSync(bak, current, "utf8");
}
fs.writeFileSync(statePath, content, "utf8");
return `✓ Restored archive #${idx} (${target.replace(".md", "")})\n Previous state backed up to CURRENT_STATE.md.bak`;
}
function cmdClear(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const statePath = path.join(memDir, "CURRENT_STATE.md");
const current = readFile(statePath);
if (current) {
// Archive before clearing
const archiveDir = path.join(memDir, "session_archive");
fs.mkdirSync(archiveDir, { recursive: true });
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`;
fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), current, "utf8");
}
const template = `# Current State
> Last updated: ${new Date().toISOString()}
## Objective
None
## Current Step
None
## Key Decisions
- None
## Next Action
None
## Blockers
None
## Unsurfaced Results
None
`;
fs.writeFileSync(statePath, template, "utf8");
return `✓ State cleared for "${agent}"\n Previous state archived.`;
}
function cmdSearch(args) {
if (!args) return "Usage: /mc search <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
const parts = args.trim().split(/\s+/);
let keyword, agent;
const agents = discoverAgents();
const agentNames = new Set(agents.map(a => a.name));
if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) {
agent = parts.pop();
keyword = parts.join(" ");
} else {
keyword = parts.join(" ");
agent = null;
}
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(escaped, "i");
const results = [];
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
for (const { name, memDir } of searchAgents) {
if (!memDir) continue;
// 1. Search current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && re.test(state)) {
re.lastIndex = 0;
const lines = state.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
}
// 2. Search archives (most recent 30)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 30)) {
re.lastIndex = 0;
const content = readFile(path.join(archiveDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
}
}
} catch {}
// 3. Search session logs (most recent 14 days)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
re.lastIndex = 0;
const content = readFile(path.join(sessionsDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
}
}
} catch {}
// 4. Search summaries (daily + weekly)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 10)) {
re.lastIndex = 0;
const content = readFile(path.join(sumDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
}
}
} catch {}
}
}
if (!results.length) return `No matches for "${keyword}".`;
// Group by type for clearer output
let out = `Search: "${keyword}" (${results.length} hits)\n`;
out += "─────────────────────────────\n";
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
for (const type of typeOrder) {
const group = results.filter(r => r.type === type);
if (group.length === 0) continue;
out += `\n[${typeLabels[type]}]\n`;
for (const r of group.slice(0, 5)) {
out += ` ${r.agent}/${r.source}\n`;
for (const line of r.matches) {
out += ` ${truncate(line.trim(), 75)}\n`;
}
}
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
}
return out.trimEnd();
}
function cmdSettings(args) {
// Read memory-continuity config from openclaw.json
const configPath = path.join(BASE, "openclaw.json");
const raw = readFile(configPath);
if (!raw) return "Cannot read openclaw.json";
let config;
try { config = JSON.parse(raw); } catch { return "Cannot parse openclaw.json"; }
const mcConfig = config?.plugins?.entries?.["memory-continuity"]?.config
|| config?.entries?.["memory-continuity"]?.config
|| {};
if (!args) {
const defaults = {
maxStateLines: { value: mcConfig.maxStateLines ?? 50, desc: "Max lines before compress warning" },
archiveOnNew: { value: mcConfig.archiveOnNew ?? true, desc: "Archive state on /new" },
autoExtract: { value: mcConfig.autoExtract ?? true, desc: "Auto-extract state at agent_end" },
maxArchiveCount: { value: mcConfig.maxArchiveCount ?? 20, desc: "Max archive files kept" },
};
let out = "MC Settings\n─────────────────────────────\n";
for (const [k, v] of Object.entries(defaults)) {
out += `${k.padEnd(18)} ${String(v.value).padStart(6)} ${v.desc}\n`;
}
// Show stats
const agents = discoverAgents();
out += "\nStats:\n";
out += ` Agents with memory: ${agents.length}\n`;
let totalArchives = 0;
for (const { memDir } of agents) {
try {
totalArchives += fs.readdirSync(path.join(memDir, "session_archive")).filter(f => f.endsWith(".md")).length;
} catch {}
}
out += ` Total archives: ${totalArchives}\n`;
return out.trimEnd();
}
// Parse "key value" for settings update
const parts = args.trim().split(/\s+/);
if (parts.length < 2 || parts[0] === "--help" || parts[0] === "-h") {
return "Usage: /mc settings <key> <value>\nKeys: maxStateLines, archiveOnNew, autoExtract, maxArchiveCount";
}
const [key, val] = parts;
const validKeys = ["maxStateLines", "archiveOnNew", "autoExtract", "maxArchiveCount"];
if (!validKeys.includes(key)) return `Unknown key: ${key}\nValid: ${validKeys.join(", ")}`;
let parsed;
if (val === "true") parsed = true;
else if (val === "false") parsed = false;
else if (!isNaN(Number(val))) parsed = Number(val);
else return `Cannot parse value: ${val}`;
// Update openclaw.json
try {
// Ensure path exists
if (!config.entries) config.entries = {};
if (!config.entries["memory-continuity"]) config.entries["memory-continuity"] = { enabled: true };
if (!config.entries["memory-continuity"].config) config.entries["memory-continuity"].config = {};
config.entries["memory-continuity"].config[key] = parsed;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
return `${key} = ${parsed}\n Restart gateway to apply.`;
} catch (e) {
return `✗ Failed to write config: ${e.message}`;
}
}
function cmdCompact(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const statePath = path.join(memDir, "CURRENT_STATE.md");
const md = readFile(statePath);
if (!md) return `No state for "${agent}".`;
const lines = md.split("\n");
const originalLen = lines.length;
// Keep headers and first meaningful line of each section
const compacted = [];
let inSection = false;
let sectionLines = 0;
for (const line of lines) {
if (line.startsWith("# ") || line.startsWith("> Last updated")) {
compacted.push(line);
inSection = false;
} else if (line.startsWith("## ")) {
compacted.push(line);
inSection = true;
sectionLines = 0;
} else if (inSection) {
sectionLines++;
if (sectionLines <= 3 || line.startsWith("- ")) {
compacted.push(line);
}
} else {
compacted.push(line);
}
}
// Update timestamp
const result = compacted.join("\n")
.replace(/^> Last updated:.*$/m, `> Last updated: ${new Date().toISOString()}`);
fs.writeFileSync(statePath, result, "utf8");
return `✓ Compacted "${agent}" state: ${originalLen}${compacted.length} lines`;
}
function cmdExport(args) {
const parts = (args || "").trim().split(/\s+/);
// Parse args: [agent|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--tag #tag]
let agent = "all";
let fromDate = null;
let toDate = null;
let filterTag = null;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "--from" && parts[i + 1]) { fromDate = parts[++i]; continue; }
if (parts[i] === "--to" && parts[i + 1]) { toDate = parts[++i]; continue; }
if (parts[i] === "--tag" && parts[i + 1]) { filterTag = parts[++i].toLowerCase(); continue; }
if (parts[i] && !parts[i].startsWith("-")) agent = parts[i];
}
const agents = agent === "all" ? discoverAgents() : [{ name: agent, memDir: resolveMemDir(agent) }];
if (agents.length === 0 || !agents[0]?.memDir) return `Agent "${agent}" not found.`;
const exportDir = path.join(BASE, "exports");
fs.mkdirSync(exportDir, { recursive: true });
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`;
const exportFile = path.join(exportDir, `mc-export-${stamp}.md`);
let content = `# Memory Continuity Export\n> Exported: ${now.toISOString()}\n`;
if (fromDate || toDate) content += `> Date range: ${fromDate || "start"} to ${toDate || "now"}\n`;
if (filterTag) content += `> Tag filter: ${filterTag}\n`;
content += "\n";
const matchesDateRange = (filename) => {
if (!fromDate && !toDate) return true;
const dateMatch = filename.match(/(\d{4}-\d{2}-\d{2})/);
if (!dateMatch) return true; // Include files without dates
const fileDate = dateMatch[1];
if (fromDate && fileDate < fromDate) return false;
if (toDate && fileDate > toDate) return false;
return true;
};
const matchesTag = (text) => {
if (!filterTag) return true;
return text.toLowerCase().includes(filterTag);
};
for (const { name, memDir } of agents) {
if (!memDir) continue;
content += `---\n# Agent: ${name}\n\n`;
// Current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && matchesTag(state)) {
content += `## Current State\n\n${state}\n\n`;
}
// Archives (filtered)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## Archives (${files.length})\n\n`;
for (const f of files) {
const ac = readFile(path.join(archiveDir, f));
if (ac && matchesTag(ac)) content += `### ${f.replace(".md", "")}\n\n${ac}\n\n`;
}
}
} catch {}
// Session logs (filtered)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## Session Logs (${files.length} days)\n\n`;
for (const f of files) {
const sc = readFile(path.join(sessionsDir, f));
if (sc && matchesTag(sc)) content += `### ${f.replace(".md", "")}\n\n${sc}\n\n`;
}
}
} catch {}
// Summaries (filtered)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## ${sub.charAt(0).toUpperCase() + sub.slice(1)} Summaries (${files.length})\n\n`;
for (const f of files) {
const sm = readFile(path.join(sumDir, f));
if (sm && matchesTag(sm)) content += `### ${f.replace(".md", "")}\n\n${sm}\n\n`;
}
}
} catch {}
}
}
fs.writeFileSync(exportFile, content, "utf8");
const sizeKB = (Buffer.byteLength(content) / 1024).toFixed(1);
return `✓ Exported to:\n ${exportFile}\n ${agents.length} agent(s), ${sizeKB} KB`;
}
function cmdSessions(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const sessionsDir = path.join(memDir, "sessions");
let files;
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No session logs for "${agent}".`; }
if (!files.length) return `No session logs for "${agent}".`;
// If date specified, show that day's log
if (dateArg) {
const target = `${dateArg}.md`;
const content = readFile(path.join(sessionsDir, target));
if (!content) return `No session log for ${dateArg}.`;
// Truncate to last 50 lines to stay compact
const lines = content.split("\n");
const shown = lines.length > 50 ? lines.slice(-50) : lines;
let out = shown.join("\n");
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
return out;
}
// List recent session logs
let out = `Session Logs: ${agent} (${files.length} days)\n`;
out += "─────────────────────────────\n";
for (const f of files.slice(0, 14)) {
const date = f.replace(".md", "");
const content = readFile(path.join(sessionsDir, f));
// Count session entries (### HH:MM headers)
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
}
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
return out.trimEnd();
}
function cmdTags(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const tagsFile = path.join(memDir, "tags.md");
const content = readFile(tagsFile);
if (!content) return `No tags for "${agent}".`;
// Parse tags and their date counts
const tagSections = content.split(/^## /gm).filter(s => s.trim());
if (tagSections.length === 0) return `No tags for "${agent}".`;
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
out += "─────────────────────────────\n";
const tags = [];
for (const section of tagSections) {
const lines = section.trim().split("\n");
const tag = lines[0].trim();
if (!tag.startsWith("#")) continue;
const dates = lines.filter(l => l.startsWith("- "));
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
}
// Sort by count descending
tags.sort((a, b) => b.count - a.count);
for (const { tag, count, latest } of tags.slice(0, 30)) {
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
}
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
return out.trimEnd();
}
function cmdSummary(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
const typeArg = parts.find(p => p === "daily" || p === "weekly");
const dateArg = parts.find(p => /^\d{4}/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
// If specific date given, show that summary
if (dateArg) {
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
const content = readFile(sumFile);
if (!content) return `No ${type} summary for ${dateArg}.`;
return content;
}
// List available summaries
const types = typeArg ? [typeArg] : ["daily", "weekly"];
let out = `Summaries: ${agent}\n`;
out += "─────────────────────────────\n";
for (const type of types) {
const sumDir = path.join(memDir, "summaries", type);
let files;
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { continue; }
if (files.length === 0) continue;
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
for (const f of files.slice(0, 10)) {
const name = f.replace(".md", "");
const content = readFile(path.join(sumDir, f));
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
const sessions = sessMatch ? sessMatch[1] : "?";
out += ` ${name} ${sessions} session(s)\n`;
}
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
}
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
return out.trimEnd();
}
function cmdRecall(args) {
if (!args) return "Usage: /mc recall <topic>\nSearches history and returns the most relevant entries for context injection.";
const topic = args.trim();
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
// Search across all agents' session logs and summaries
const words = topic
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf-]/g, " ")
.split(/\s+/)
.filter(w => w.length > 2)
.map(w => w.toLowerCase());
if (words.length === 0) return "Need more specific topic keywords.";
const scored = [];
for (const { name, memDir } of agents) {
if (!memDir) continue;
// Search session logs
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(sessionsDir, f));
if (!content) continue;
const entries = content.split(/^### /gm).filter(e => e.trim());
for (const entry of entries) {
let score = 0;
const lower = entry.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const time = entry.match(/^(\d{2}:\d{2})/)?.[1] || "";
scored.push({
agent: name,
date: `${f.replace(".md", "")} ${time}`,
score,
text: topicMatch?.[1] || entry.split("\n")[0].slice(0, 80)
});
}
}
}
} catch {}
// Search daily summaries
const dailyDir = path.join(memDir, "summaries", "daily");
try {
const files = fs.readdirSync(dailyDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(dailyDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicsMatch = content.match(/## (?:Key )?Topics\n([\s\S]*?)(?:\n##|$)/);
const topics = topicsMatch ? topicsMatch[1].trim().split("\n").slice(0, 3).join("; ") : "";
scored.push({
agent: name,
date: f.replace(".md", ""),
score,
text: topics || "(daily summary)"
});
}
}
} catch {}
// Search archives
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 20)) {
const content = readFile(path.join(archiveDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const objMatch = content.match(/## Objective\n(.+)/);
scored.push({
agent: name,
date: f.replace(".md", "").replace(/_/g, " "),
score,
text: objMatch?.[1]?.slice(0, 80) || "(archived state)"
});
}
}
} catch {}
}
if (scored.length === 0) return `No relevant history found for "${topic}".`;
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 10);
let out = `Recall: "${topic}" (${scored.length} matches, showing top ${top.length})\n`;
out += "─────────────────────────────\n";
for (const item of top) {
out += `[${item.agent}] ${item.date} (score: ${item.score})\n`;
out += ` ${truncate(item.text, 80)}\n\n`;
}
return out.trimEnd();
}
function cmdSubagents(args) {
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
let out = "Subagent State Overview\n";
out += "═══════════════════════\n\n";
for (const { name, memDir } of agents) {
const statePath = path.join(memDir, "CURRENT_STATE.md");
const content = readFile(statePath);
if (!content) {
out += `[${name}] No state file\n\n`;
continue;
}
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
const objective = objMatch?.[1]?.trim() || "None";
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
const updated = updatedMatch?.[1]?.trim() || "unknown";
out += `[${name}] Updated: ${updated}\n`;
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
if (unsurfaced !== "None") {
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
}
out += "\n";
}
return out.trimEnd();
}
function cmdHelp() {
return `MC Commands (Memory Continuity)
─────────────────────────────
/mc state [agent] View current state (default: main)
/mc state --all Overview of all agents
/mc sessions [date] Session logs (daily activity)
/mc summary [daily|weekly] List or view summaries
/mc tags [agent] View tag index
/mc history [agent] List archived sessions
/mc restore <N> [agent] Restore archive #N
/mc clear [agent] Clear state (archives first)
/mc search <keyword> Search across all memory
/mc settings View MC settings
/mc settings <k> <v> Update a setting
/mc compact [agent] Compress state file
/mc export [agent|all] Export (--from/--to/--tag)
/mc recall <topic> Find relevant history by topic
/mc subagents Subagent state overview across workspaces`;
}
// ── Plugin entry point ──────────────────────────────────────────────────
export default function (api) {
console.log("[mc] MC plugin loading, registering /mc command...");
api.registerCommand({
name: "mc",
description: "Memory Continuity commands — state, history, search, settings, etc.",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const raw = (ctx.args || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
let text;
switch (subcmd) {
case "state":
text = subargs === "--all" ? cmdStateAll() : cmdState(subargs || null);
break;
case "sessions": text = cmdSessions(subargs || null); break;
case "history": text = cmdHistory(subargs || null); break;
case "restore": text = cmdRestore(subargs); break;
case "clear": text = cmdClear(subargs || null); break;
case "search": text = cmdSearch(subargs); break;
case "settings": text = cmdSettings(subargs || null); break;
case "compact": text = cmdCompact(subargs || null); break;
case "export": text = cmdExport(subargs || null); break;
case "tags": text = cmdTags(subargs || null); break;
case "summary": text = cmdSummary(subargs || null); break;
case "recall": text = cmdRecall(subargs); break;
case "subagents": text = cmdSubagents(subargs || null); break;
case "help": case "--help": case "-h": case "":
text = cmdHelp(); break;
default:
text = `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}`;
}
return { text: mono(text) };
} catch (err) {
return { text: `MC error: ${err.message}` };
}
},
});
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "mc",
"name": "Memory Continuity Commands",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc settings, etc.",
"version": "2.1.0",
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
"license": "MIT",
"category": "commands",
"tags": ["memory", "commands", "search", "recall"],
"icon": "terminal",
"requires": ["memory-continuity"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"memoryDir": {
"type": "string",
"default": "",
"description": "Override memory directory path (auto-detected from workspace if empty)"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "mc-plugin",
"version": "2.1.0",
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "mc",
"pluginManifest": "openclaw.plugin.json"
}
}
+100 -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": "5.0.1",
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
"license": "MIT",
"repository": "https://github.com/dtzp555-max/memory-continuity",
"category": "memory",
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
"icon": "brain",
"minOpenClawVersion": "2026.3.0",
"source": "https://github.com/dtzp555-max/memory-continuity", "source": "https://github.com/dtzp555-max/memory-continuity",
"interop": {
"complements": [
{
"id": "lossless-claw",
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
}
],
"conflicts": [],
"slot": "hooks-only",
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -27,6 +45,51 @@
"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)"
},
"subagentSeed": {
"type": "boolean",
"default": true,
"description": "Inject parent working state into subagent context at startup"
},
"subagentRecovery": {
"type": "boolean",
"default": true,
"description": "Recover unsurfaced results from completed subagents back to parent"
} }
} }
}, },
@@ -46,6 +109,42 @@
"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)"
},
"subagentSeed": {
"label": "Subagent seeding",
"help": "Give subagents parent context so they understand the broader task"
},
"subagentRecovery": {
"label": "Subagent recovery",
"help": "Pull completed subagent results back into parent state"
} }
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "memory-continuity", "name": "memory-continuity",
"version": "2.7.0", "version": "5.0.1",
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.", "description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -8,7 +8,7 @@
"type": "git", "type": "git",
"url": "https://github.com/dtzp555-max/memory-continuity.git" "url": "https://github.com/dtzp555-max/memory-continuity.git"
}, },
"keywords": ["openclaw", "plugin", "memory", "continuity"], "keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
"author": "dtzp555-max", "author": "dtzp555-max",
"license": "MIT", "license": "MIT",
"openclaw": { "openclaw": {
+158
View File
@@ -0,0 +1,158 @@
# ContextEngine Variant Evaluation
## Status
Decision document. Phase 5 evaluation as outlined in plugin-design.md.
## Executive Summary
**Decision: Do not build a ContextEngine variant.**
Memory Continuity already achieves its core recovery goals through lifecycle hooks and `before_prompt_build`. The contextEngine slot is exclusive, and occupying it would break coexistence with lossless-claw and future context engines -- destroying MC's strongest ecosystem advantage. The marginal gains from `assemble()` and `systemPromptAddition` do not justify the slot cost, especially since `prependSystemContext` via `before_prompt_build` already provides prompt-time injection.
---
## What contextEngine provides vs current hooks approach
### ContextEngine slot capabilities (theoretical)
| Capability | Description |
|---|---|
| `assemble()` | Full control over context assembly -- decides what goes into the prompt, in what order, with what priority |
| `systemPromptAddition` | Guaranteed system prompt injection with engine-level priority |
| Context window management | Direct control over token budgets, message pruning, and compression strategy |
| Turn-level interception | Can modify or rewrite every message before it reaches the model |
### What MC currently uses (hooks-only)
| Capability | Implementation |
|---|---|
| `before_prompt_build` | Injects continuity snapshot via `prependSystemContext` -- works today, confirmed in phase 2 validation |
| `command:new` | Archives checkpoint before `/new` reset |
| `agent_end` | Safety checkpoint at session end |
| `before_compaction` | Protection checkpoint before compaction |
| `subagent_ended` | Child-to-parent result recovery |
| Session logging, summaries, relevance injection | All implemented via hooks without needing the engine slot |
### Gap analysis: what would a ContextEngine variant actually gain?
1. **`assemble()` -- full context control**: MC does not need this. MC's job is to inject a 150-300 token snapshot at startup and protect state at boundaries. It does not need to control the entire context assembly pipeline. That is a context compression concern (lossless-claw's domain).
2. **`systemPromptAddition`**: MC already achieves equivalent functionality via `prependSystemContext` in the `before_prompt_build` hook. Phase 2 validation confirmed this works. The injection is not ContextEngine-exclusive.
3. **Token budget management**: MC's snapshot is deliberately small (~150-300 tokens). It does not need fine-grained token budget control. Oversized state files are handled by the `maxStateLines` config and `mc compact` command.
4. **Turn-level interception**: MC does not need to modify arbitrary turns. Its concern is boundary events (startup, /new, compaction, session end), all of which are already covered by lifecycle hooks.
**Conclusion**: The practical capabilities MC needs are already available through the hooks API. The additional capabilities from the contextEngine slot solve problems MC does not have.
---
## Slot conflict analysis
### The core constraint
OpenClaw's `contextEngine` is an **exclusive slot** -- only one plugin can occupy it at a time.
### Impact on lossless-claw coexistence
MC's `openclaw.plugin.json` explicitly declares:
- `"slot": "hooks-only"`
- `"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."`
- `"complements": [{ "id": "lossless-claw", ... }]`
If MC became a contextEngine:
- Users would be forced to choose between MC and lossless-claw
- Context compression is a broader, more fundamental need than working-state recovery
- Most users who want MC also want context compression -- making them mutually exclusive would reduce adoption of both
### Composite/multi-engine support
Research found **no evidence** of composite engine, engine chaining, or multi-engine support in OpenClaw:
- No `contextEngine` array support in plugin schema
- No engine composition layer in runtime code
- No roadmap references to multi-engine support in available documentation
- The design documents themselves note this as a precondition: "only pursue if... composite-engine support exists"
**That precondition has not been met.**
### Ecosystem risk
MC's hooks-only design is a competitive advantage:
- It is the only memory plugin that explicitly complements rather than competes with context engines
- Converting to a contextEngine would make MC just another exclusive-slot plugin competing for the same position
- The interop declaration in `openclaw.plugin.json` is a trust signal to users that MC respects their plugin choices
---
## Risk/Benefit Matrix
### Building a ContextEngine variant
| Factor | Assessment |
|---|---|
| **Benefit: Better prompt injection** | Low -- `prependSystemContext` already works |
| **Benefit: Full context control** | Irrelevant -- MC does not need context assembly control |
| **Benefit: Token budget awareness** | Minimal -- MC's snapshots are already small by design |
| **Risk: Breaks lossless-claw coexistence** | **Critical** -- destroys MC's key ecosystem advantage |
| **Risk: Reduced adoption** | **High** -- users forced into either/or choice |
| **Risk: Maintenance burden** | Medium -- two codepaths (hooks version + engine version) to maintain |
| **Risk: Feature creep** | High -- engine slot invites scope expansion into context compression territory |
| **Risk: No composite engine fallback** | **Critical** -- if composite support never ships, the variant is permanently exclusive |
### Keeping hooks-only
| Factor | Assessment |
|---|---|
| **Benefit: Coexists with all context engines** | **Critical** -- unique market position |
| **Benefit: Simpler architecture** | High -- one codepath, clear scope boundaries |
| **Benefit: Lower maintenance** | High -- no engine API surface to track |
| **Benefit: Aligned with design principles** | High -- scope.md principle #8: "Ecosystem compatibility matters" |
| **Risk: Missing prompt-time power** | Low -- `before_prompt_build` covers the actual need |
| **Risk: Weaker injection guarantee** | Low -- not observed as a real problem in practice |
---
## Recommendation
**Do not build a ContextEngine variant. Invest in hook-based improvements instead.**
### Rationale
1. The two preconditions from plugin-design.md Phase 5 are both unmet:
- "slot tradeoffs are acceptable" -- they are not; lossless-claw coexistence is too valuable
- "composite-engine support exists" -- it does not
2. MC's actual prompt injection needs are satisfied by `before_prompt_build` + `prependSystemContext`
3. The contextEngine API solves context assembly and compression problems that are outside MC's scope
4. Converting would destroy MC's strongest differentiator: being the only memory plugin that complements rather than competes with context engines
### What to do instead: hook improvements
#### Priority 1 -- Strengthen existing injection
- Validate `prependSystemContext` reliability across OpenClaw versions and model backends
- Add fallback to `before_agent_start` if `before_prompt_build` proves unreliable in edge cases
- Improve snapshot quality (better summarization, freshness labeling)
#### Priority 2 -- Better compaction protection
- Confirm `before_compaction` is truly synchronous (the probe exists but needs production validation)
- Add tail-message protection quality metrics
- Consider `after_compaction` hook for post-compaction state verification
#### Priority 3 -- Smarter relevance injection
- The `relevanceInjection` feature already injects historical context at startup
- Improve relevance scoring without needing engine-level token budget control
- Keep injection budget self-contained (MC manages its own token ceiling)
#### Priority 4 -- Monitor OpenClaw evolution
- Watch for composite-engine or engine-delegation support in future OpenClaw releases
- If OpenClaw adds a way for hooks to register "guaranteed system prompt sections" at engine priority without taking the slot, adopt that immediately
- Re-evaluate this decision if the exclusive-slot constraint changes
---
## Conditions for revisiting this decision
Re-evaluate if ANY of these become true:
1. OpenClaw adds composite/multi-engine support (multiple contextEngines can coexist)
2. OpenClaw adds a "system prompt section" API at engine priority, available to non-engine plugins
3. `before_prompt_build` + `prependSystemContext` proves unreliable in a way that cannot be fixed via hooks
4. A significant user cohort explicitly requests engine-level context control from MC and does not use lossless-claw
Until then, the hooks-only architecture remains correct.
---
## Document history
- 2026-03-31: Initial evaluation. Decision: do not build.