mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-21 21:15:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d74a0b4e2 | ||
|
|
69df869335 | ||
|
|
50ff9befa7 | ||
|
|
d020d46b41 | ||
|
|
f14a1a0249 | ||
|
|
85bb1be946 | ||
|
|
a7aeb673b7 | ||
|
|
29d2d18ed5 | ||
|
|
54c7ba65e7 | ||
|
|
9c21baf461 | ||
|
|
cad5e54aca | ||
|
|
e859f3bb48 | ||
|
|
92f7739db9 | ||
|
|
c5401d5c93 | ||
|
|
06c546084c | ||
|
|
e9046829b5 | ||
|
|
a0cd296ec7 | ||
|
|
9d4968c44b | ||
|
|
e4ac7acaed | ||
|
|
8db3154cff | ||
|
|
cc0aad38cb | ||
|
|
a5feb5e0e5 | ||
|
|
e885eb393f | ||
|
|
03b1da86fd | ||
|
|
8ea0feeb55 | ||
|
|
e12f3a7a73 | ||
|
|
e9faf21d0c | ||
|
|
fb0fe6b40b | ||
|
|
2184442bc2 | ||
|
|
719723225d | ||
|
|
9f43452f03 | ||
|
|
fe13ea19be | ||
|
|
772c964740 | ||
|
|
b12bd15c8b | ||
|
|
c366d3ec47 | ||
|
|
449f1f4916 | ||
|
|
6a60c1806d | ||
|
|
9e25ec5e95 | ||
|
|
312d892853 | ||
|
|
33c3558625 | ||
|
|
201c52bf75 | ||
|
|
d61c242e9f |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# memory-continuity
|
# memory-continuity
|
||||||
|
|
||||||
**Current release:** `v2.1.0`
|
**Current release:** `v4.0.0`
|
||||||
|
|
||||||
OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
|
OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction.
|
||||||
|
|
||||||
@@ -46,6 +46,37 @@ Because state injection happens at the hook level (before the model sees anythin
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Marketplace Install (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Works with lossless-claw
|
||||||
|
|
||||||
|
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
|
||||||
|
|
||||||
|
- **lossless-claw** = lossless context compression (contextEngine slot)
|
||||||
|
- **memory-continuity** = working-state recovery (hooks only)
|
||||||
|
|
||||||
|
Install both for the best experience.
|
||||||
|
|
||||||
|
### Programmatic API
|
||||||
|
|
||||||
|
Other plugins can call MC's recall function programmatically:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In another plugin's register() function:
|
||||||
|
const recall = api.getService("mc:recall");
|
||||||
|
if (recall) {
|
||||||
|
const result = await recall.handler(
|
||||||
|
{ topic: "deployment issues", maxItems: 3 },
|
||||||
|
ctx
|
||||||
|
);
|
||||||
|
// result.results = [{ date, type, score, summary }, ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -69,6 +100,18 @@ No npm install, no API keys, no external database.
|
|||||||
### Verify
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Quick 3-layer install check (files → tool → workspace state)
|
||||||
|
bash scripts/verify.sh
|
||||||
|
|
||||||
|
# Show a sample high-importance state entry
|
||||||
|
bash scripts/verify.sh --sample
|
||||||
|
|
||||||
|
# Check against a custom workspace
|
||||||
|
bash scripts/verify.sh --workspace ~/.openclaw/workspace/myproject
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alternatively, confirm the gateway loaded the plugin
|
||||||
openclaw gateway restart 2>&1 | grep memory-continuity
|
openclaw gateway restart 2>&1 | grep memory-continuity
|
||||||
# Should show: [memory-continuity] Plugin registered successfully
|
# Should show: [memory-continuity] Plugin registered successfully
|
||||||
```
|
```
|
||||||
@@ -80,6 +123,62 @@ openclaw gateway restart 2>&1 | grep memory-continuity
|
|||||||
3. Ask "what was the secret?" or "我们刚才聊到哪了"
|
3. Ask "what was the secret?" or "我们刚才聊到哪了"
|
||||||
4. The agent should immediately surface the recovered state
|
4. The agent should immediately surface the recovered state
|
||||||
|
|
||||||
|
## Interactive Commands (`/mc`)
|
||||||
|
|
||||||
|
v3.0.0 adds a companion plugin that registers `/mc` as a native slash command. Works in Telegram, Discord, and anywhere OpenClaw commands are supported.
|
||||||
|
|
||||||
|
```
|
||||||
|
/mc state View main agent's working state
|
||||||
|
/mc state --all Overview of all agents
|
||||||
|
/mc state tech_geek View a specific agent's state
|
||||||
|
/mc history List archived sessions
|
||||||
|
/mc restore 3 Restore archive #3
|
||||||
|
/mc search auth Search "auth" across all memory
|
||||||
|
/mc settings View plugin settings
|
||||||
|
/mc settings maxArchiveCount 30 Update a setting
|
||||||
|
/mc compact Compress oversized state file
|
||||||
|
/mc export all Export all agents' memory to file
|
||||||
|
/mc --help Command reference
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/mc` plugin reads memory files directly — no HTTP endpoints, no model invocation. Responses are instant.
|
||||||
|
|
||||||
|
### Install the command plugin
|
||||||
|
|
||||||
|
The `/mc` command plugin is separate from the lifecycle plugin. To install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone (if you haven't already)
|
||||||
|
cd ~/.openclaw/projects
|
||||||
|
git clone https://github.com/dtzp555-max/memory-continuity.git
|
||||||
|
|
||||||
|
# The mc-plugin is bundled in the mc-plugin/ directory
|
||||||
|
cp -r memory-continuity/mc-plugin ~/.openclaw/projects/mc-plugin
|
||||||
|
mkdir -p ~/.openclaw/extensions/mc
|
||||||
|
cp ~/.openclaw/projects/mc-plugin/* ~/.openclaw/extensions/mc/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add to `openclaw.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"allow": ["memory-continuity", "mc"],
|
||||||
|
"entries": {
|
||||||
|
"mc": { "enabled": true }
|
||||||
|
},
|
||||||
|
"installs": {
|
||||||
|
"mc": {
|
||||||
|
"source": "path",
|
||||||
|
"sourcePath": "~/.openclaw/projects/mc-plugin",
|
||||||
|
"installPath": "~/.openclaw/extensions/mc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway and `/mc --help` should work.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The plugin works with zero configuration. Optional settings in `openclaw.json`:
|
The plugin works with zero configuration. Optional settings in `openclaw.json`:
|
||||||
@@ -110,6 +209,7 @@ The plugin works with zero configuration. Optional settings in `openclaw.json`:
|
|||||||
| `maxStateLines` | `50` | Max lines for CURRENT_STATE.md |
|
| `maxStateLines` | `50` | Max lines for CURRENT_STATE.md |
|
||||||
| `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` |
|
| `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` |
|
||||||
| `autoExtract` | `true` | Auto-extract state from conversation at session end |
|
| `autoExtract` | `true` | Auto-extract state from conversation at session end |
|
||||||
|
| `maxArchiveCount` | `20` | Maximum archive files to keep (oldest auto-deleted) |
|
||||||
|
|
||||||
## The checkpoint file
|
## The checkpoint file
|
||||||
|
|
||||||
@@ -157,6 +257,47 @@ scp $WORKSPACE/memory/CURRENT_STATE.md newhost:$WORKSPACE/memory/
|
|||||||
|
|
||||||
No database, no vector embeddings, no API keys to transfer.
|
No database, no vector embeddings, no API keys to transfer.
|
||||||
|
|
||||||
|
## Recovery after OpenClaw upgrade
|
||||||
|
|
||||||
|
OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the plugin stops working after an upgrade, follow these steps:
|
||||||
|
|
||||||
|
### Quick diagnosis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if the plugin is loaded
|
||||||
|
openclaw gateway restart 2>&1 | grep memory-continuity
|
||||||
|
# Expected: [memory-continuity] Plugin registered successfully
|
||||||
|
|
||||||
|
# Verify config is intact
|
||||||
|
cat ~/.openclaw/openclaw.json | grep -A2 memory-continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common issues and fixes
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| No `Plugin registered` in startup log | Plugin files missing or config lost | Re-run `bash scripts/post-install.sh` |
|
||||||
|
| `plugins.allow is empty` warning | `plugins.allow` was cleared in config | Add `"plugins.allow": ["memory-continuity"]` |
|
||||||
|
| `loaded without provenance` warning | `plugins.installs` record missing | Add `"plugins.installs": {"memory-continuity": {"source": "path"}}` |
|
||||||
|
| New version changed hook API | OpenClaw breaking change | Check [CHANGELOG](CHANGELOG.md), update `index.js` |
|
||||||
|
| State not recovering, no errors | Session cached stale skillsSnapshot | `/new` to start fresh session, or re-run `post-install.sh` (clears cache) |
|
||||||
|
|
||||||
|
### One-command recovery
|
||||||
|
|
||||||
|
The install script is idempotent — safe to re-run at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.openclaw/projects/memory-continuity # or wherever you cloned it
|
||||||
|
git pull # pull latest version
|
||||||
|
bash scripts/post-install.sh # reinstall + restart gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-upgrade backup (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture: Plugin vs Skill
|
## Architecture: Plugin vs Skill
|
||||||
|
|
||||||
Previous versions (v0.x) shipped as a **skill** — a markdown file that asked the model to read/write `CURRENT_STATE.md`. This was unreliable because models could ignore the instructions.
|
Previous versions (v0.x) shipped as a **skill** — a markdown file that asked the model to read/write `CURRENT_STATE.md`. This was unreliable because models could ignore the instructions.
|
||||||
@@ -189,6 +330,7 @@ memory-continuity/
|
|||||||
│ └── phase2-hook-validation.md
|
│ └── phase2-hook-validation.md
|
||||||
└── scripts/
|
└── scripts/
|
||||||
├── post-install.sh # Automated installer
|
├── post-install.sh # Automated installer
|
||||||
|
├── verify.sh # 3-layer install verifier
|
||||||
└── continuity_doctor.py # Health check
|
└── continuity_doctor.py # Health check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -200,6 +342,12 @@ memory-continuity/
|
|||||||
4. **Portable and backupable** — `cp` is your backup tool
|
4. **Portable and backupable** — `cp` is your backup tool
|
||||||
5. **Complements native OpenClaw memory** — does not replace it
|
5. **Complements native OpenClaw memory** — does not replace it
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you find this plugin useful, please consider giving it a ⭐ on GitHub — it helps others discover the project!
|
||||||
|
|
||||||
|
[](https://github.com/dtzp555-max/memory-continuity)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ Update the file by **overwriting** it, not appending, at these moments:
|
|||||||
| Before handoff / subagent exit | Preserves outputs and unsurfaced results |
|
| Before handoff / subagent exit | Preserves outputs and unsurfaced results |
|
||||||
| After a substantive state change | Keeps checkpoint aligned with actual work |
|
| After a substantive state change | Keeps checkpoint aligned with actual work |
|
||||||
|
|
||||||
|
### Override rule
|
||||||
|
|
||||||
|
**CURRENT_STATE.md must always be overwritten when:**
|
||||||
|
- A new task or objective starts — regardless of what is currently in the file
|
||||||
|
- The previous objective is complete or abandoned
|
||||||
|
- The user gives a new task that supersedes the previous one
|
||||||
|
|
||||||
|
Having content in CURRENT_STATE.md does NOT mean it should be preserved.
|
||||||
|
Content only matters if Objective is still active and work is genuinely in progress.
|
||||||
|
|
||||||
|
Checking before overwrite:
|
||||||
|
- Read the file
|
||||||
|
- If Objective matches the current task → update in place (overwrite)
|
||||||
|
- If Objective does NOT match → overwrite the entire file with the new state
|
||||||
|
- Never append. Never skip the update because "there's already something there".
|
||||||
|
|
||||||
### 4. Keep the checkpoint small
|
### 4. Keep the checkpoint small
|
||||||
|
|
||||||
`CURRENT_STATE.md` should usually stay under about 40 lines and be readable in
|
`CURRENT_STATE.md` should usually stay under about 40 lines and be readable in
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# v3.1 Short-Term Memory Layer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add session logging, smart tail protection during compaction, CJK token awareness, and noise filtering to the Memory Continuity plugin.
|
||||||
|
|
||||||
|
**Architecture:** Four independent features added to the existing lifecycle hook system. Session logs write to `memory/sessions/YYYY-MM-DD.md` (one file per day, append-only). Tail protection injects recent critical messages into `before_compaction`. CJK token estimation uses a helper function for line-count decisions. Ignore patterns filter out cron/subagent noise at `agent_end`.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| `index.js` | Modify | Add session logging in `agent_end`, enhance `before_compaction` with tail protection, add CJK helper, add ignore-pattern filtering |
|
||||||
|
| `openclaw.plugin.json` | Modify | Add new config keys: `sessionLogging`, `tailProtectCount`, `ignorePatterns` |
|
||||||
|
| `mc-plugin/index.js` | Modify | Add `/mc sessions` subcommand, update help text |
|
||||||
|
| `mc-plugin/openclaw.plugin.json` | No change | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: CJK Token Estimation Helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (after line 15, the PLACEHOLDER_VALUES block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `estimateTokens` helper function to `index.js`**
|
||||||
|
|
||||||
|
Add this after the `PLACEHOLDER_VALUES` constant (line 15):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Estimate token count with CJK awareness.
|
||||||
|
* CJK characters ≈ 1.5 tokens each; Latin words ≈ 1 token per ~4 chars.
|
||||||
|
*/
|
||||||
|
function estimateTokens(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
// Count CJK characters (CJK Unified Ideographs + common CJK ranges)
|
||||||
|
const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g) || []).length;
|
||||||
|
// Remove CJK chars, count remaining as ~1 token per 4 chars
|
||||||
|
const nonCjk = text.replace(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/g, "");
|
||||||
|
const latinTokens = Math.ceil(nonCjk.length / 4);
|
||||||
|
return Math.ceil(cjkCount * 1.5) + latinTokens;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `truncate` in `extractStateFromMessages` to use token-aware truncation**
|
||||||
|
|
||||||
|
In `extractStateFromMessages` (around line 175), replace the existing `truncate` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Token-aware truncation
|
||||||
|
const truncate = (s, maxTokens = 200) => {
|
||||||
|
if (estimateTokens(s) <= maxTokens) return s;
|
||||||
|
// Binary search for the right cut point
|
||||||
|
let lo = 0, hi = s.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi + 1) >> 1;
|
||||||
|
if (estimateTokens(s.slice(0, mid)) <= maxTokens) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return s.slice(0, lo) + "...";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat: add CJK token-aware estimation helper (v3.1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Ignore Patterns — Filter Cron/Subagent Noise
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (agent_end hook, around line 295)
|
||||||
|
- Modify: `openclaw.plugin.json` (add `ignorePatterns` config)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `ignorePatterns` to `openclaw.plugin.json` config schema**
|
||||||
|
|
||||||
|
Add after the `maxArchiveCount` property block (after line 29):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ignorePatterns": {
|
||||||
|
"type": "array",
|
||||||
|
"default": [],
|
||||||
|
"description": "Regex patterns to ignore sessions (e.g. cron jobs, subagent noise). Matched against first user message."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And add corresponding uiHints after the `maxArchiveCount` hint (after line 47):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ignorePatterns": {
|
||||||
|
"label": "Ignore patterns",
|
||||||
|
"help": "Skip state extraction for sessions matching these patterns (regex)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ignore-pattern check in `agent_end` hook in `index.js`**
|
||||||
|
|
||||||
|
In the `agent_end` hook, after the `realUserMsgs` filtering block (after line 321), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check ignore patterns — skip sessions matching cron/subagent noise
|
||||||
|
const ignorePatterns = (config.ignorePatterns || [])
|
||||||
|
.map(p => { try { return new RegExp(p, "i"); } catch { return null; } })
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (ignorePatterns.length > 0 && realUserMsgs.length > 0) {
|
||||||
|
const firstMsg = realUserMsgs[0];
|
||||||
|
if (ignorePatterns.some(re => re.test(firstMsg))) {
|
||||||
|
log.info?.("[memory-continuity] Session matches ignorePattern, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js openclaw.plugin.json
|
||||||
|
git commit -m "feat: add ignorePatterns to filter cron/subagent noise (v3.1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Session Logging
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add `writeSessionLog` helper + call it in `agent_end`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `writeSessionLog` helper function to `index.js`**
|
||||||
|
|
||||||
|
Add after the `cleanupMemoryDir` function (after line 140):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Append a session summary to the daily session log.
|
||||||
|
* File: memory/sessions/YYYY-MM-DD.md (one per day, append-only)
|
||||||
|
*/
|
||||||
|
function writeSessionLog(workspaceDir, messages, config = {}) {
|
||||||
|
if (config.sessionLogging === false) return;
|
||||||
|
if (!messages || messages.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
|
||||||
|
const sessionsDir = path.join(workspaceDir, "memory", "sessions");
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
|
const logFile = path.join(sessionsDir, `${dateStr}.md`);
|
||||||
|
|
||||||
|
// Extract first meaningful user message as topic
|
||||||
|
let topic = "(no topic)";
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg?.role !== "user") continue;
|
||||||
|
const text = typeof msg.content === "string"
|
||||||
|
? msg.content
|
||||||
|
: Array.isArray(msg.content)
|
||||||
|
? msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n")
|
||||||
|
: "";
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/^Conversation info \(untrusted metadata\):[\s\S]*?\n\n/m, "")
|
||||||
|
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
|
||||||
|
.trim();
|
||||||
|
if (cleaned.length > 10) {
|
||||||
|
topic = cleaned.split("\n")[0].slice(0, 120);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count messages by role
|
||||||
|
const userCount = messages.filter(m => m?.role === "user").length;
|
||||||
|
const assistantCount = messages.filter(m => m?.role === "assistant").length;
|
||||||
|
const totalTokens = estimateTokens(
|
||||||
|
messages.map(m => typeof m?.content === "string" ? m.content : "").join("")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build log entry
|
||||||
|
const entry = [
|
||||||
|
`### ${timeStr}`,
|
||||||
|
`- **Topic:** ${topic}`,
|
||||||
|
`- **Messages:** ${userCount} user / ${assistantCount} assistant`,
|
||||||
|
`- **Est. tokens:** ~${totalTokens}`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Append to daily log (create with header if new)
|
||||||
|
if (!fs.existsSync(logFile)) {
|
||||||
|
const header = `# Session Log — ${dateStr}\n\n`;
|
||||||
|
fs.writeFileSync(logFile, header + entry, "utf8");
|
||||||
|
} else {
|
||||||
|
fs.appendFileSync(logFile, entry, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `sessionLogging` config to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Add after the `ignorePatterns` property in the config schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sessionLogging": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Write session summaries to memory/sessions/ daily logs"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And add corresponding uiHints:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sessionLogging": {
|
||||||
|
"label": "Session logging",
|
||||||
|
"help": "Append session summaries to daily markdown logs"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Call `writeSessionLog` from `agent_end` hook**
|
||||||
|
|
||||||
|
In the `agent_end` hook, right before the `const existing = readFile(statePath);` line (around line 324), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Write session log entry
|
||||||
|
writeSessionLog(ws, messages, config);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js openclaw.plugin.json
|
||||||
|
git commit -m "feat: add session logging to memory/sessions/ daily files (v3.1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Smart Tail Protection in `before_compaction`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (enhance `before_compaction` hook, around line 254)
|
||||||
|
- Modify: `openclaw.plugin.json` (add `tailProtectCount` config)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `tailProtectCount` config to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Add after the `sessionLogging` property in the config schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"tailProtectCount": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 3,
|
||||||
|
"description": "Number of recent critical message pairs to protect during compaction"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And add corresponding uiHints:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"tailProtectCount": {
|
||||||
|
"label": "Tail protect count",
|
||||||
|
"help": "Keep N recent user/assistant pairs visible after compaction"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `extractTailMessages` helper to `index.js`**
|
||||||
|
|
||||||
|
Add after the `writeSessionLog` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Extract the last N meaningful user/assistant exchange pairs from messages.
|
||||||
|
* Returns a formatted string for injection into compaction context.
|
||||||
|
*/
|
||||||
|
function extractTailMessages(messages, count = 3) {
|
||||||
|
if (!messages || messages.length === 0) return null;
|
||||||
|
|
||||||
|
// Walk backwards, collect up to `count` user+assistant pairs
|
||||||
|
const pairs = [];
|
||||||
|
let i = messages.length - 1;
|
||||||
|
|
||||||
|
while (i >= 0 && pairs.length < count) {
|
||||||
|
// Find assistant message
|
||||||
|
while (i >= 0 && messages[i]?.role !== "assistant") i--;
|
||||||
|
if (i < 0) break;
|
||||||
|
const assistantMsg = messages[i];
|
||||||
|
i--;
|
||||||
|
|
||||||
|
// Find preceding user message
|
||||||
|
while (i >= 0 && messages[i]?.role !== "user") i--;
|
||||||
|
if (i < 0) break;
|
||||||
|
const userMsg = messages[i];
|
||||||
|
i--;
|
||||||
|
|
||||||
|
const getText = (msg) => {
|
||||||
|
if (typeof msg?.content === "string") return msg.content;
|
||||||
|
if (Array.isArray(msg?.content)) {
|
||||||
|
return msg.content.filter(b => b?.type === "text").map(b => b.text).join("\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const userText = getText(userMsg).trim();
|
||||||
|
const assistantText = getText(assistantMsg).trim();
|
||||||
|
|
||||||
|
// Skip trivial exchanges
|
||||||
|
if (userText.length < 10 && assistantText.length < 20) continue;
|
||||||
|
|
||||||
|
// Token-aware truncation per message
|
||||||
|
const maxPerMsg = 150;
|
||||||
|
const truncMsg = (s) => {
|
||||||
|
if (estimateTokens(s) <= maxPerMsg) return s;
|
||||||
|
let lo = 0, hi = s.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi + 1) >> 1;
|
||||||
|
if (estimateTokens(s.slice(0, mid)) <= maxPerMsg) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return s.slice(0, lo) + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
pairs.unshift({ user: truncMsg(userText), assistant: truncMsg(assistantText) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairs.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = ["=== RECENT EXCHANGE (protected) ==="];
|
||||||
|
for (const p of pairs) {
|
||||||
|
lines.push(`User: ${p.user}`);
|
||||||
|
lines.push(`Assistant: ${p.assistant}`);
|
||||||
|
lines.push("---");
|
||||||
|
}
|
||||||
|
lines.push("=== END RECENT EXCHANGE ===");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Enhance `before_compaction` hook to inject tail messages**
|
||||||
|
|
||||||
|
Replace the existing `before_compaction` hook body (lines 254-270) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
api.on("before_compaction", async (_event, _ctx) => {
|
||||||
|
const ws = _ctx?.workspaceDir;
|
||||||
|
const config = getConfig();
|
||||||
|
const statePath = resolveStatePath(ws);
|
||||||
|
if (!statePath) return;
|
||||||
|
|
||||||
|
const md = readFile(statePath);
|
||||||
|
if (!md) return;
|
||||||
|
|
||||||
|
const snapshot = buildSnapshot(md);
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
log.info?.("[memory-continuity] Injecting state before compaction");
|
||||||
|
|
||||||
|
// Smart tail protection: also inject recent critical messages
|
||||||
|
const tailCount = config.tailProtectCount ?? 3;
|
||||||
|
const messages = _event?.messages;
|
||||||
|
const tail = tailCount > 0 ? extractTailMessages(messages, tailCount) : null;
|
||||||
|
|
||||||
|
const parts = [snapshot];
|
||||||
|
if (tail) {
|
||||||
|
parts.push(tail);
|
||||||
|
log.info?.("[memory-continuity] Tail protection: injected recent exchanges");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prependSystemContext: parts.join("\n\n"),
|
||||||
|
};
|
||||||
|
}, { priority: 10 });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js openclaw.plugin.json
|
||||||
|
git commit -m "feat: smart tail protection during compaction (v3.1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `/mc sessions` Command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mc-plugin/index.js` (add `cmdSessions` function + wire into switch)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `cmdSessions` handler to `mc-plugin/index.js`**
|
||||||
|
|
||||||
|
Add after the `cmdExport` function (before `cmdHelp`, around line 437):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function cmdSessions(args) {
|
||||||
|
const parts = (args || "").trim().split(/\s+/);
|
||||||
|
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
|
||||||
|
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
|
||||||
|
|
||||||
|
const memDir = resolveMemDir(agent);
|
||||||
|
if (!memDir) return `Agent "${agent}" not found.`;
|
||||||
|
|
||||||
|
const sessionsDir = path.join(memDir, "sessions");
|
||||||
|
let files;
|
||||||
|
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
|
||||||
|
catch { return `No session logs for "${agent}".`; }
|
||||||
|
|
||||||
|
if (!files.length) return `No session logs for "${agent}".`;
|
||||||
|
|
||||||
|
// If date specified, show that day's log
|
||||||
|
if (dateArg) {
|
||||||
|
const target = `${dateArg}.md`;
|
||||||
|
const content = readFile(path.join(sessionsDir, target));
|
||||||
|
if (!content) return `No session log for ${dateArg}.`;
|
||||||
|
// Truncate to last 50 lines to stay compact
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const shown = lines.length > 50 ? lines.slice(-50) : lines;
|
||||||
|
let out = shown.join("\n");
|
||||||
|
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List recent session logs
|
||||||
|
let out = `Session Logs: ${agent} (${files.length} days)\n`;
|
||||||
|
out += "─────────────────────────────\n";
|
||||||
|
|
||||||
|
for (const f of files.slice(0, 14)) {
|
||||||
|
const date = f.replace(".md", "");
|
||||||
|
const content = readFile(path.join(sessionsDir, f));
|
||||||
|
// Count session entries (### HH:MM headers)
|
||||||
|
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
|
||||||
|
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
|
||||||
|
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire `sessions` into the command switch**
|
||||||
|
|
||||||
|
In the switch statement (around line 471), add a new case before `help`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
case "sessions": text = cmdSessions(subargs || null); break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `cmdHelp` to include `sessions`**
|
||||||
|
|
||||||
|
Add this line in the help text after the `/mc state --all` line:
|
||||||
|
|
||||||
|
```
|
||||||
|
/mc sessions [date] Session logs (daily activity)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mc-plugin/index.js
|
||||||
|
git commit -m "feat: add /mc sessions command for daily session logs (v3.1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Version Bump and Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `openclaw.plugin.json` (version 3.0.0 → 3.1.0)
|
||||||
|
- Modify: `mc-plugin/openclaw.plugin.json` (version if present)
|
||||||
|
- Modify: `package.json` (version if present)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version in `openclaw.plugin.json` to 3.1.0**
|
||||||
|
|
||||||
|
Change `"version": "3.0.0"` to `"version": "3.1.0"`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bump version in `package.json` to 3.1.0**
|
||||||
|
|
||||||
|
Change `"version"` value to `"3.1.0"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify both plugins parse correctly**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
|
||||||
|
```
|
||||||
|
Expected: `All OK`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit and tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add openclaw.plugin.json package.json
|
||||||
|
git commit -m "release: v3.1.0 — short-term memory layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Push to GitHub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of New Config Keys
|
||||||
|
|
||||||
|
| Key | Type | Default | Purpose |
|
||||||
|
|-----|------|---------|---------|
|
||||||
|
| `ignorePatterns` | `string[]` | `[]` | Regex patterns to skip sessions (cron/subagent) |
|
||||||
|
| `sessionLogging` | `boolean` | `true` | Enable daily session log files |
|
||||||
|
| `tailProtectCount` | `number` | `3` | Recent exchange pairs to protect during compaction |
|
||||||
|
|
||||||
|
## Summary of New/Changed Hooks
|
||||||
|
|
||||||
|
| Hook | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `agent_end` | +session logging, +ignore pattern filtering, +CJK-aware truncation |
|
||||||
|
| `before_compaction` | +tail message protection |
|
||||||
|
|
||||||
|
## New Command
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/mc sessions [date]` | List daily session logs or view specific day |
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# v3.2 Mid-Term Memory Layer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add layered markdown summaries (daily/weekly), enhanced cross-source search, and automatic tag extraction to the Memory Continuity plugin.
|
||||||
|
|
||||||
|
**Architecture:** Three independent features layered on v3.1's session logging. Daily summaries are generated from session logs at `agent_end` when the day changes. Weekly summaries roll up dailies every Monday. Enhanced search extends the existing `/mc search` to cover sessions + summaries with context lines. Auto-tagging scans session entries for `#tag` patterns and maintains a `memory/tags.md` index. All pure markdown, zero dependencies.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js (ES modules), pure filesystem, zero dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| `index.js` | Modify | Add `generateDailySummary`, `generateWeeklySummary`, call from `agent_end` |
|
||||||
|
| `openclaw.plugin.json` | Modify | Add `summaryEnabled` config |
|
||||||
|
| `mc-plugin/index.js` | Modify | Enhance `cmdSearch` to cover sessions+summaries, add `/mc tags` and `/mc summary` commands |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Daily Summary Generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add helper + call in agent_end)
|
||||||
|
- Modify: `openclaw.plugin.json` (add `summaryEnabled` config)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `summaryEnabled` config to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Read `openclaw.plugin.json`. Add after the `tailProtectCount` property in configSchema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"summaryEnabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generate daily and weekly summaries from session logs"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add corresponding uiHints after the `tailProtectCount` hint:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"summaryEnabled": {
|
||||||
|
"label": "Summary generation",
|
||||||
|
"help": "Auto-generate daily/weekly summaries from session logs"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `generateDailySummary` helper to `index.js`**
|
||||||
|
|
||||||
|
Read `index.js`. Add after the `extractTailMessages` function (ends around line 302), before `extractStateFromMessages`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Generate a daily summary from the previous day's session log.
|
||||||
|
* Only runs when today differs from the last summary date.
|
||||||
|
* Output: memory/summaries/daily/YYYY-MM-DD.md
|
||||||
|
*/
|
||||||
|
function generateDailySummary(workspaceDir, config = {}) {
|
||||||
|
if (config.summaryEnabled === false) return;
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
const now = new Date();
|
||||||
|
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
|
||||||
|
// Check yesterday's date
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yStr = `${yesterday.getFullYear()}-${pad(yesterday.getMonth() + 1)}-${pad(yesterday.getDate())}`;
|
||||||
|
|
||||||
|
const summaryDir = path.join(workspaceDir, "memory", "summaries", "daily");
|
||||||
|
const summaryFile = path.join(summaryDir, `${yStr}.md`);
|
||||||
|
|
||||||
|
// Skip if summary already exists for yesterday
|
||||||
|
if (fs.existsSync(summaryFile)) return;
|
||||||
|
|
||||||
|
// Read yesterday's session log
|
||||||
|
const sessionFile = path.join(workspaceDir, "memory", "sessions", `${yStr}.md`);
|
||||||
|
const sessionContent = readFile(sessionFile);
|
||||||
|
if (!sessionContent) return; // No sessions yesterday
|
||||||
|
|
||||||
|
// Parse session entries
|
||||||
|
const entries = sessionContent.split(/^### /gm).filter(e => e.trim());
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
const topics = [];
|
||||||
|
let totalUser = 0;
|
||||||
|
let totalAssistant = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
|
||||||
|
const msgMatch = entry.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
|
||||||
|
const tokenMatch = entry.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
|
||||||
|
|
||||||
|
if (topicMatch) topics.push(topicMatch[1].trim());
|
||||||
|
if (msgMatch) {
|
||||||
|
totalUser += parseInt(msgMatch[1]);
|
||||||
|
totalAssistant += parseInt(msgMatch[2]);
|
||||||
|
}
|
||||||
|
if (tokenMatch) totalTokens += parseInt(tokenMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build daily summary
|
||||||
|
const summary = [
|
||||||
|
`# Daily Summary — ${yStr}`,
|
||||||
|
"",
|
||||||
|
`**Sessions:** ${entries.length}`,
|
||||||
|
`**Messages:** ${totalUser} user / ${totalAssistant} assistant`,
|
||||||
|
`**Est. tokens:** ~${totalTokens}`,
|
||||||
|
"",
|
||||||
|
"## Topics",
|
||||||
|
...topics.map((t, i) => `${i + 1}. ${t}`),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
fs.mkdirSync(summaryDir, { recursive: true });
|
||||||
|
fs.writeFileSync(summaryFile, summary, "utf8");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Call `generateDailySummary` from `agent_end` hook**
|
||||||
|
|
||||||
|
In the `agent_end` hook, right after the `writeSessionLog(ws, messages, config);` line (around line 515), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Generate daily summary for previous day if needed
|
||||||
|
generateDailySummary(ws, config);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
Expected: no output (syntax OK)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js openclaw.plugin.json
|
||||||
|
git commit -m "feat: auto-generate daily summaries from session logs (v3.2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Weekly Summary Generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add `generateWeeklySummary` + call in agent_end)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `generateWeeklySummary` helper to `index.js`**
|
||||||
|
|
||||||
|
Read `index.js`. Add after the `generateDailySummary` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Generate a weekly summary by rolling up daily summaries.
|
||||||
|
* Runs on Monday, summarizing the previous week (Mon-Sun).
|
||||||
|
* Output: memory/summaries/weekly/YYYY-Www.md (ISO week number)
|
||||||
|
*/
|
||||||
|
function generateWeeklySummary(workspaceDir, config = {}) {
|
||||||
|
if (config.summaryEnabled === false) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Only run on Mondays
|
||||||
|
if (now.getDay() !== 1) return;
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
// Calculate previous week's Monday
|
||||||
|
const prevMonday = new Date(now);
|
||||||
|
prevMonday.setDate(prevMonday.getDate() - 7);
|
||||||
|
|
||||||
|
// ISO week number
|
||||||
|
const jan1 = new Date(prevMonday.getFullYear(), 0, 1);
|
||||||
|
const weekNum = Math.ceil(((prevMonday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
|
||||||
|
const weekLabel = `${prevMonday.getFullYear()}-W${pad(weekNum)}`;
|
||||||
|
|
||||||
|
const weeklyDir = path.join(workspaceDir, "memory", "summaries", "weekly");
|
||||||
|
const weeklyFile = path.join(weeklyDir, `${weekLabel}.md`);
|
||||||
|
|
||||||
|
// Skip if already generated
|
||||||
|
if (fs.existsSync(weeklyFile)) return;
|
||||||
|
|
||||||
|
// Collect daily summaries for the 7 days of previous week
|
||||||
|
const dailyDir = path.join(workspaceDir, "memory", "summaries", "daily");
|
||||||
|
const dailies = [];
|
||||||
|
let weekSessions = 0;
|
||||||
|
let weekUser = 0;
|
||||||
|
let weekAssistant = 0;
|
||||||
|
let weekTokens = 0;
|
||||||
|
const allTopics = [];
|
||||||
|
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const day = new Date(prevMonday);
|
||||||
|
day.setDate(day.getDate() + d);
|
||||||
|
const dayStr = `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
|
||||||
|
const dailyFile = path.join(dailyDir, `${dayStr}.md`);
|
||||||
|
const content = readFile(dailyFile);
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
dailies.push(dayStr);
|
||||||
|
|
||||||
|
const sessMatch = content.match(/\*\*Sessions:\*\*\s*(\d+)/);
|
||||||
|
const msgMatch = content.match(/\*\*Messages:\*\*\s*(\d+)\s*user\s*\/\s*(\d+)\s*assistant/);
|
||||||
|
const tokenMatch = content.match(/\*\*Est\. tokens:\*\*\s*~(\d+)/);
|
||||||
|
|
||||||
|
if (sessMatch) weekSessions += parseInt(sessMatch[1]);
|
||||||
|
if (msgMatch) {
|
||||||
|
weekUser += parseInt(msgMatch[1]);
|
||||||
|
weekAssistant += parseInt(msgMatch[2]);
|
||||||
|
}
|
||||||
|
if (tokenMatch) weekTokens += parseInt(tokenMatch[1]);
|
||||||
|
|
||||||
|
// Extract topics
|
||||||
|
const topicSection = content.split("## Topics")[1];
|
||||||
|
if (topicSection) {
|
||||||
|
const topics = topicSection.match(/^\d+\.\s+(.+)$/gm);
|
||||||
|
if (topics) allTopics.push(...topics.map(t => t.replace(/^\d+\.\s+/, "").trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dailies.length === 0) return;
|
||||||
|
|
||||||
|
// Deduplicate topics (keep first occurrence)
|
||||||
|
const seen = new Set();
|
||||||
|
const uniqueTopics = allTopics.filter(t => {
|
||||||
|
const key = t.toLowerCase().slice(0, 50);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
`# Weekly Summary — ${weekLabel}`,
|
||||||
|
`> ${dailies[0]} to ${dailies[dailies.length - 1]}`,
|
||||||
|
"",
|
||||||
|
`**Active days:** ${dailies.length}/7`,
|
||||||
|
`**Total sessions:** ${weekSessions}`,
|
||||||
|
`**Total messages:** ${weekUser} user / ${weekAssistant} assistant`,
|
||||||
|
`**Est. total tokens:** ~${weekTokens}`,
|
||||||
|
"",
|
||||||
|
"## Key Topics",
|
||||||
|
...uniqueTopics.slice(0, 20).map((t, i) => `${i + 1}. ${t}`),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
fs.mkdirSync(weeklyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(weeklyFile, summary, "utf8");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Call `generateWeeklySummary` from `agent_end` hook**
|
||||||
|
|
||||||
|
Right after the `generateDailySummary(ws, config);` line, add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
generateWeeklySummary(ws, config);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat: auto-generate weekly summaries from daily rollups (v3.2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Auto-Tagging — Extract `#tag` from Session Logs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add `updateTagIndex` helper + call in `writeSessionLog`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `updateTagIndex` helper to `index.js`**
|
||||||
|
|
||||||
|
Read `index.js`. Add after the `generateWeeklySummary` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Extract #tags from a session topic and update the tag index.
|
||||||
|
* Tags file: memory/tags.md — simple markdown index mapping tags to dates.
|
||||||
|
*/
|
||||||
|
function updateTagIndex(workspaceDir, topic, dateStr) {
|
||||||
|
if (!topic) return;
|
||||||
|
|
||||||
|
// Extract #tags (word chars + hyphens after #)
|
||||||
|
const tags = topic.match(/#[\w-]+/g);
|
||||||
|
if (!tags || tags.length === 0) return;
|
||||||
|
|
||||||
|
const tagsFile = path.join(workspaceDir, "memory", "tags.md");
|
||||||
|
let existing = readFile(tagsFile) || "# Tag Index\n\n";
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const normalizedTag = tag.toLowerCase();
|
||||||
|
// Check if this tag+date combo already exists
|
||||||
|
if (existing.includes(`${normalizedTag}`) && existing.includes(dateStr)) continue;
|
||||||
|
|
||||||
|
// Find or create tag section
|
||||||
|
const tagHeader = `## ${normalizedTag}`;
|
||||||
|
if (existing.includes(tagHeader)) {
|
||||||
|
// Append date to existing tag section
|
||||||
|
existing = existing.replace(
|
||||||
|
tagHeader,
|
||||||
|
`${tagHeader}\n- ${dateStr}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add new tag section
|
||||||
|
existing += `${tagHeader}\n- ${dateStr}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(tagsFile, existing);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Call `updateTagIndex` from `writeSessionLog`**
|
||||||
|
|
||||||
|
In the `writeSessionLog` function, right after the `fs.appendFileSync(logFile, prefix + entry, "utf8");` line (the last line of the function), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Extract and index #tags from the topic
|
||||||
|
updateTagIndex(workspaceDir, topic, dateStr);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat: auto-extract #tags from sessions to memory/tags.md (v3.2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Enhanced Search — Cross Sessions + Summaries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mc-plugin/index.js` (enhance `cmdSearch`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Enhance `cmdSearch` to search sessions and summaries**
|
||||||
|
|
||||||
|
Read `mc-plugin/index.js`. Replace the entire `cmdSearch` function (lines 220-279) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function cmdSearch(args) {
|
||||||
|
if (!args) return "Usage: /mc search <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
|
||||||
|
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
let keyword, agent;
|
||||||
|
const agents = discoverAgents();
|
||||||
|
const agentNames = new Set(agents.map(a => a.name));
|
||||||
|
|
||||||
|
if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) {
|
||||||
|
agent = parts.pop();
|
||||||
|
keyword = parts.join(" ");
|
||||||
|
} else {
|
||||||
|
keyword = parts.join(" ");
|
||||||
|
agent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const re = new RegExp(keyword, "gi");
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
|
||||||
|
|
||||||
|
for (const { name, memDir } of searchAgents) {
|
||||||
|
if (!memDir) continue;
|
||||||
|
|
||||||
|
// 1. Search current state
|
||||||
|
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
|
||||||
|
if (state && re.test(state)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const lines = state.split("\n").filter(l => re.test(l));
|
||||||
|
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search archives (most recent 30)
|
||||||
|
const archiveDir = path.join(memDir, "session_archive");
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse();
|
||||||
|
for (const f of files.slice(0, 30)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const content = readFile(path.join(archiveDir, f));
|
||||||
|
if (content && re.test(content)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const lines = content.split("\n").filter(l => re.test(l));
|
||||||
|
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 3. Search session logs (most recent 14 days)
|
||||||
|
const sessionsDir = path.join(memDir, "sessions");
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
|
||||||
|
for (const f of files.slice(0, 14)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const content = readFile(path.join(sessionsDir, f));
|
||||||
|
if (content && re.test(content)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const lines = content.split("\n").filter(l => re.test(l));
|
||||||
|
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 4. Search summaries (daily + weekly)
|
||||||
|
for (const sub of ["daily", "weekly"]) {
|
||||||
|
const sumDir = path.join(memDir, "summaries", sub);
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
|
||||||
|
for (const f of files.slice(0, 10)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const content = readFile(path.join(sumDir, f));
|
||||||
|
if (content && re.test(content)) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
const lines = content.split("\n").filter(l => re.test(l));
|
||||||
|
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results.length) return `No matches for "${keyword}".`;
|
||||||
|
|
||||||
|
// Group by type for clearer output
|
||||||
|
let out = `Search: "${keyword}" (${results.length} hits)\n`;
|
||||||
|
out += "─────────────────────────────\n";
|
||||||
|
|
||||||
|
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
|
||||||
|
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
|
||||||
|
|
||||||
|
for (const type of typeOrder) {
|
||||||
|
const group = results.filter(r => r.type === type);
|
||||||
|
if (group.length === 0) continue;
|
||||||
|
|
||||||
|
out += `\n[${typeLabels[type]}]\n`;
|
||||||
|
for (const r of group.slice(0, 5)) {
|
||||||
|
out += ` ${r.agent}/${r.source}\n`;
|
||||||
|
for (const line of r.matches) {
|
||||||
|
out += ` ${truncate(line.trim(), 75)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mc-plugin/index.js
|
||||||
|
git commit -m "feat: enhanced search across sessions and summaries (v3.2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `/mc tags` and `/mc summary` Commands
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mc-plugin/index.js` (add two command handlers + wire switch + update help)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `cmdTags` handler to `mc-plugin/index.js`**
|
||||||
|
|
||||||
|
Read `mc-plugin/index.js`. Add before the `cmdHelp` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function cmdTags(args) {
|
||||||
|
const agent = args || "main";
|
||||||
|
const memDir = resolveMemDir(agent);
|
||||||
|
if (!memDir) return `Agent "${agent}" not found.`;
|
||||||
|
|
||||||
|
const tagsFile = path.join(memDir, "tags.md");
|
||||||
|
const content = readFile(tagsFile);
|
||||||
|
if (!content) return `No tags for "${agent}".`;
|
||||||
|
|
||||||
|
// Parse tags and their date counts
|
||||||
|
const tagSections = content.split(/^## /gm).filter(s => s.trim());
|
||||||
|
if (tagSections.length === 0) return `No tags for "${agent}".`;
|
||||||
|
|
||||||
|
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
|
||||||
|
out += "─────────────────────────────\n";
|
||||||
|
|
||||||
|
const tags = [];
|
||||||
|
for (const section of tagSections) {
|
||||||
|
const lines = section.trim().split("\n");
|
||||||
|
const tag = lines[0].trim();
|
||||||
|
if (!tag.startsWith("#")) continue;
|
||||||
|
const dates = lines.filter(l => l.startsWith("- "));
|
||||||
|
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by count descending
|
||||||
|
tags.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
for (const { tag, count, latest } of tags.slice(0, 30)) {
|
||||||
|
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `cmdSummary` handler**
|
||||||
|
|
||||||
|
Add after `cmdTags`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function cmdSummary(args) {
|
||||||
|
const parts = (args || "").trim().split(/\s+/);
|
||||||
|
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
|
||||||
|
const typeArg = parts.find(p => p === "daily" || p === "weekly");
|
||||||
|
const dateArg = parts.find(p => /^\d{4}/.test(p));
|
||||||
|
|
||||||
|
const memDir = resolveMemDir(agent);
|
||||||
|
if (!memDir) return `Agent "${agent}" not found.`;
|
||||||
|
|
||||||
|
// If specific date given, show that summary
|
||||||
|
if (dateArg) {
|
||||||
|
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
|
||||||
|
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
|
||||||
|
const content = readFile(sumFile);
|
||||||
|
if (!content) return `No ${type} summary for ${dateArg}.`;
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List available summaries
|
||||||
|
const types = typeArg ? [typeArg] : ["daily", "weekly"];
|
||||||
|
let out = `Summaries: ${agent}\n`;
|
||||||
|
out += "─────────────────────────────\n";
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const sumDir = path.join(memDir, "summaries", type);
|
||||||
|
let files;
|
||||||
|
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
if (files.length === 0) continue;
|
||||||
|
|
||||||
|
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
|
||||||
|
for (const f of files.slice(0, 10)) {
|
||||||
|
const name = f.replace(".md", "");
|
||||||
|
const content = readFile(path.join(sumDir, f));
|
||||||
|
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
|
||||||
|
const sessions = sessMatch ? sessMatch[1] : "?";
|
||||||
|
out += ` ${name} ${sessions} session(s)\n`;
|
||||||
|
}
|
||||||
|
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire `tags` and `summary` into the command switch**
|
||||||
|
|
||||||
|
In the switch statement, add before the `help` case:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
case "tags": text = cmdTags(subargs || null); break;
|
||||||
|
case "summary": text = cmdSummary(subargs || null); break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `cmdHelp` to include new commands**
|
||||||
|
|
||||||
|
Add these lines after `/mc sessions [date]`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/mc summary [daily|weekly] List or view summaries
|
||||||
|
/mc tags [agent] View tag index
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify no syntax errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c mc-plugin/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mc-plugin/index.js
|
||||||
|
git commit -m "feat: add /mc tags and /mc summary commands (v3.2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Version Bump and Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `openclaw.plugin.json` (version 3.1.0 → 3.2.0)
|
||||||
|
- Modify: `package.json` (version 3.1.0 → 3.2.0)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version in both files to 3.2.0**
|
||||||
|
|
||||||
|
Change `"version": "3.1.0"` to `"version": "3.2.0"` in both `openclaw.plugin.json` and `package.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify all files parse correctly**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity && node -c index.js && node -c mc-plugin/index.js && node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" && echo "All OK"
|
||||||
|
```
|
||||||
|
Expected: `All OK`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add openclaw.plugin.json package.json
|
||||||
|
git commit -m "release: v3.2.0 — mid-term memory layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push to GitHub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of New Features
|
||||||
|
|
||||||
|
| Feature | Files | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| Daily summaries | `memory/summaries/daily/YYYY-MM-DD.md` | Auto-generated from previous day's session log |
|
||||||
|
| Weekly summaries | `memory/summaries/weekly/YYYY-Www.md` | Rolls up daily summaries every Monday |
|
||||||
|
| Auto-tagging | `memory/tags.md` | Extracts `#tag` from session topics, maintains index |
|
||||||
|
| Enhanced search | `/mc search` | Now covers state + archives + sessions + summaries, grouped by type |
|
||||||
|
| `/mc summary` | mc-plugin | List/view daily and weekly summaries |
|
||||||
|
| `/mc tags` | mc-plugin | View tag index with counts and latest dates |
|
||||||
|
|
||||||
|
## New Config Keys
|
||||||
|
|
||||||
|
| Key | Type | Default | Purpose |
|
||||||
|
|-----|------|---------|---------|
|
||||||
|
| `summaryEnabled` | `boolean` | `true` | Enable/disable summary generation |
|
||||||
|
|
||||||
|
## New Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/mc summary [daily\|weekly]` | List summaries or view specific one |
|
||||||
|
| `/mc summary <date>` | View specific daily/weekly summary |
|
||||||
|
| `/mc tags [agent]` | View tag index |
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# Phase 4 — Ecosystem Integration (v4.0) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Package Memory Continuity for OpenClaw marketplace publishing, declare complementary positioning with lossless-claw, and expose a programmatic skill interface so other plugins can call `/mc recall`.
|
||||||
|
|
||||||
|
**Architecture:** Three independent additions — (1) marketplace metadata in manifests + README update, (2) interop declaration via `openclaw.plugin.json` `interop` field and docs, (3) a new `api.exposeService()` call in the lifecycle plugin that other plugins can query for recall results without going through the slash command.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM, OpenClaw plugin API (`api.exposeService`, `api.registerCommand`), markdown files, zero external deps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Modify | `openclaw.plugin.json` | Add marketplace metadata fields (author, license, icon, category, interop) |
|
||||||
|
| Modify | `package.json` | Bump to 4.0.0, add marketplace fields |
|
||||||
|
| Modify | `mc-plugin/package.json` | Bump to 2.0.0 |
|
||||||
|
| Modify | `mc-plugin/openclaw.plugin.json` | Bump to 2.0.0, add marketplace category |
|
||||||
|
| Modify | `index.js` | Add `api.exposeService("mc:recall", ...)` in `register()` |
|
||||||
|
| Modify | `README.md` | Update version, add marketplace install section, interop docs, skill API docs |
|
||||||
|
| Create | `MARKETPLACE.md` | Marketplace listing long description (separate from README for marketplace scraping) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Marketplace Metadata for Lifecycle Plugin
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `openclaw.plugin.json`
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
The OpenClaw `plugins install` command supports `--marketplace <source>` and `openclaw plugins marketplace list <source>`. The manifest needs enriched metadata for discoverability.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add marketplace fields to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Open `openclaw.plugin.json` and add the following fields alongside the existing content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "memory-continuity",
|
||||||
|
"name": "Memory Continuity",
|
||||||
|
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
|
||||||
|
"version": "4.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "dtzp555-max",
|
||||||
|
"url": "https://github.com/dtzp555-max"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/dtzp555-max/memory-continuity",
|
||||||
|
"category": "memory",
|
||||||
|
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
|
||||||
|
"icon": "brain",
|
||||||
|
"minOpenClawVersion": "2026.3.0",
|
||||||
|
"source": "https://github.com/dtzp555-max/memory-continuity",
|
||||||
|
"configSchema": { ... existing configSchema unchanged ... },
|
||||||
|
"uiHints": { ... existing uiHints unchanged ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key additions:
|
||||||
|
- `author` — object with name and URL
|
||||||
|
- `license` — MIT
|
||||||
|
- `category` — "memory" (standard marketplace category)
|
||||||
|
- `tags` — array for search/filter
|
||||||
|
- `icon` — emoji-name for UI display
|
||||||
|
- `minOpenClawVersion` — minimum compatible version
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `package.json` to v4.0.0**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "memory-continuity",
|
||||||
|
"version": "4.0.0",
|
||||||
|
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dtzp555-max/memory-continuity.git"
|
||||||
|
},
|
||||||
|
"keywords": ["openclaw", "plugin", "memory", "continuity", "state", "recovery", "markdown"],
|
||||||
|
"author": "dtzp555-max",
|
||||||
|
"license": "MIT",
|
||||||
|
"openclaw": {
|
||||||
|
"type": "plugin",
|
||||||
|
"id": "memory-continuity",
|
||||||
|
"pluginManifest": "openclaw.plugin.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify plugin still loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: object`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add openclaw.plugin.json package.json
|
||||||
|
git commit -m "feat(marketplace): add marketplace metadata to lifecycle plugin manifest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Marketplace Metadata for MC Commands Plugin
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mc-plugin/openclaw.plugin.json`
|
||||||
|
- Modify: `mc-plugin/package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add marketplace fields to `mc-plugin/openclaw.plugin.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "mc",
|
||||||
|
"name": "Memory Continuity Commands",
|
||||||
|
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc recall, /mc settings, etc.",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "dtzp555-max",
|
||||||
|
"url": "https://github.com/dtzp555-max"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "commands",
|
||||||
|
"tags": ["memory", "commands", "search", "recall"],
|
||||||
|
"icon": "terminal",
|
||||||
|
"requires": ["memory-continuity"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"memoryDir": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": "Override memory directory path (auto-detected from workspace if empty)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key addition: `requires` — declares dependency on the `memory-continuity` lifecycle plugin.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bump `mc-plugin/package.json` to 2.0.0**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "mc-plugin",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
|
||||||
|
"license": "MIT",
|
||||||
|
"openclaw": {
|
||||||
|
"type": "plugin",
|
||||||
|
"id": "mc",
|
||||||
|
"pluginManifest": "openclaw.plugin.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify mc-plugin still loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.openclaw/projects/memory-continuity/mc-plugin
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: function`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mc-plugin/openclaw.plugin.json mc-plugin/package.json
|
||||||
|
git commit -m "feat(marketplace): add marketplace metadata to mc-plugin manifest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: lossless-claw Interop Declaration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `openclaw.plugin.json` (add `interop` field)
|
||||||
|
- Create: `MARKETPLACE.md`
|
||||||
|
|
||||||
|
The key design point: MC uses lifecycle hooks (not the `contextEngine` slot), so it coexists with lossless-claw. MC handles working-state recovery; lossless-claw handles full context compression. They complement each other.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `interop` field to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Add the following field to the root of the manifest (after `minOpenClawVersion`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"interop": {
|
||||||
|
"complements": [
|
||||||
|
{
|
||||||
|
"id": "lossless-claw",
|
||||||
|
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conflicts": [],
|
||||||
|
"slot": "hooks-only",
|
||||||
|
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `MARKETPLACE.md`**
|
||||||
|
|
||||||
|
This file provides the long-form marketplace listing description (separate from README which is for developers).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Memory Continuity — Marketplace Listing
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Memory Continuity is a **zero-dependency lifecycle plugin** that preserves your working state across session resets, compaction, and gateway restarts. It answers one question:
|
||||||
|
|
||||||
|
> What were we doing, where did we stop, and what should happen next?
|
||||||
|
|
||||||
|
## Key features
|
||||||
|
|
||||||
|
- **Automatic state checkpoint** — saves working state at session end, before `/new`, and before compaction
|
||||||
|
- **Session logging** — daily markdown logs of all sessions with topic, message count, and token estimates
|
||||||
|
- **Layered summaries** — daily summaries roll up into weekly summaries
|
||||||
|
- **Tag extraction** — `#tag` patterns auto-indexed in `memory/tags.md`
|
||||||
|
- **Relevance injection** — injects related history at session start based on keyword matching
|
||||||
|
- **Memory decay** — old archives move to cold storage after configurable days
|
||||||
|
- **CJK-aware** — proper token estimation for Chinese/Japanese/Korean text
|
||||||
|
- **Search & recall** — `/mc search` and `/mc recall` for cross-memory search
|
||||||
|
|
||||||
|
## Why this plugin?
|
||||||
|
|
||||||
|
| Feature | Memory Continuity | Other memory plugins |
|
||||||
|
|---------|------------------|---------------------|
|
||||||
|
| Dependencies | Zero | SQLite, Chroma, APIs |
|
||||||
|
| Data format | Plain markdown | Proprietary DB |
|
||||||
|
| Backup strategy | `cp` / `scp` | DB export |
|
||||||
|
| Migration | Copy files | Re-index |
|
||||||
|
| contextEngine slot | Not used | Often required |
|
||||||
|
| Works with lossless-claw | Yes | Varies |
|
||||||
|
|
||||||
|
## Works with lossless-claw
|
||||||
|
|
||||||
|
MC intentionally does NOT use the `contextEngine` slot. It runs via lifecycle hooks only. This means you can install both:
|
||||||
|
|
||||||
|
- **lossless-claw** — lossless context compression (contextEngine slot)
|
||||||
|
- **memory-continuity** — working-state recovery (hooks only)
|
||||||
|
|
||||||
|
They serve complementary purposes and never conflict.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are optional with sensible defaults. See `openclaw plugins inspect memory-continuity` for the full config schema.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add openclaw.plugin.json MARKETPLACE.md
|
||||||
|
git commit -m "feat(interop): declare lossless-claw complementary positioning + marketplace listing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Programmatic Skill Interface — `mc:recall` Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add `api.exposeService` in `register()`)
|
||||||
|
|
||||||
|
The goal: other plugins can programmatically call `api.getService("mc:recall")` to get recall results as structured data, without parsing slash command output.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `findRelevantHistory` re-export as a service**
|
||||||
|
|
||||||
|
At the end of the `register(api)` function in `index.js`, before the final log line, add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SERVICE: mc:recall — programmatic interface for other plugins
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
if (typeof api.exposeService === "function") {
|
||||||
|
api.exposeService("mc:recall", {
|
||||||
|
description: "Search memory history by topic keywords. Returns scored results.",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.topic - Keywords to search for
|
||||||
|
* @param {number} [params.maxItems=5] - Maximum results to return
|
||||||
|
* @param {string} [params.format="structured"] - "structured" returns array of objects, "text" returns formatted string
|
||||||
|
* @returns {{ results: Array<{ date: string, type: string, score: number, summary: string }>, total: number }}
|
||||||
|
*/
|
||||||
|
async handler(params, ctx) {
|
||||||
|
const topic = params?.topic;
|
||||||
|
if (!topic || typeof topic !== "string") {
|
||||||
|
return { error: "topic (string) is required", results: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = ctx?.workspaceDir;
|
||||||
|
if (!ws) {
|
||||||
|
return { error: "no workspace context", results: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxItems = Math.min(params?.maxItems ?? 5, 20);
|
||||||
|
|
||||||
|
// Reuse the internal findRelevantHistory function
|
||||||
|
const textResult = findRelevantHistory(ws, topic, maxItems);
|
||||||
|
|
||||||
|
if (!textResult) {
|
||||||
|
return { results: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "text" format, return the raw string (same as before_agent_start injection)
|
||||||
|
if (params?.format === "text") {
|
||||||
|
return { text: textResult, total: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "structured" format, parse the result into objects
|
||||||
|
const lines = textResult.split("\n").filter(l => l.startsWith("["));
|
||||||
|
const results = lines.map(line => {
|
||||||
|
const dateMatch = line.match(/^\[([^\]]+)\]/);
|
||||||
|
const summary = line.replace(/^\[[^\]]+\]\s*/, "").trim();
|
||||||
|
return {
|
||||||
|
date: dateMatch?.[1] || "unknown",
|
||||||
|
type: "history",
|
||||||
|
score: 0,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { results, total: results.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info?.("[memory-continuity] Exposed mc:recall service for inter-plugin use");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify plugin still loads with the service**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default, typeof m.default.register))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: object function`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat(skill-api): expose mc:recall as programmatic service for inter-plugin use"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: README Update
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update README header and install section**
|
||||||
|
|
||||||
|
Update the version line at the top:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Current release:** `v4.0.0`
|
||||||
|
```
|
||||||
|
|
||||||
|
Add after the existing "Quick Start" / "Install" section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Marketplace Install (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins install https://github.com/dtzp555-max/memory-continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Works with lossless-claw
|
||||||
|
|
||||||
|
This plugin does **not** use the `contextEngine` slot. It runs via lifecycle hooks only, so it coexists perfectly with lossless-claw or any other context engine:
|
||||||
|
|
||||||
|
- **lossless-claw** = lossless context compression (contextEngine slot)
|
||||||
|
- **memory-continuity** = working-state recovery (hooks only)
|
||||||
|
|
||||||
|
Install both for the best experience.
|
||||||
|
|
||||||
|
### Programmatic API
|
||||||
|
|
||||||
|
Other plugins can call MC's recall function programmatically:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In another plugin's register() function:
|
||||||
|
const recall = api.getService("mc:recall");
|
||||||
|
if (recall) {
|
||||||
|
const result = await recall.handler(
|
||||||
|
{ topic: "deployment issues", maxItems: 3 },
|
||||||
|
ctx
|
||||||
|
);
|
||||||
|
// result.results = [{ date, type, score, summary }, ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update README with marketplace install, interop, and skill API sections"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final Version Bump + Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: all modified files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify all version numbers match**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `openclaw.plugin.json` → `"4.0.0"`
|
||||||
|
- `package.json` → `"4.0.0"`
|
||||||
|
- `mc-plugin/openclaw.plugin.json` → `"2.0.0"`
|
||||||
|
- `mc-plugin/package.json` → `"2.0.0"`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify both plugins load**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('lifecycle:', typeof m.default))"
|
||||||
|
node -e "import('./mc-plugin/index.js').then(m => console.log('mc-plugin:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
lifecycle: object
|
||||||
|
mc-plugin: function
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Copy to extensions and restart gateway**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
|
||||||
|
cp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json ~/.openclaw/extensions/mc/ 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify with `openclaw plugins inspect`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins inspect memory-continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: shows `Version: 4.0.0` and all 5 hooks.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final commit + tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "release: v4.0.0 — ecosystem integration (marketplace, interop, skill API)"
|
||||||
|
git tag v4.0.0
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Deploy to All 3 Servers
|
||||||
|
|
||||||
|
**Files:** None (deployment only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Deploy to Cloud (152.67.121.35)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp index.js package.json openclaw.plugin.json MARKETPLACE.md opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
|
||||||
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
|
||||||
|
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Deploy to PI (172.16.2.232)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp index.js package.json openclaw.plugin.json MARKETPLACE.md administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
|
||||||
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
|
||||||
|
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Restart Mac gateway**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
|
||||||
|
pkill -f 'openclaw.*gateway'; sleep 1; nohup openclaw gateway start > /dev/null 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify all 3 servers**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep version ~/.openclaw/extensions/memory-continuity/package.json
|
||||||
|
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
||||||
|
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all show `"version": "4.0.0"`.
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
# Phase 5 — Conservative Subagent Support (v5.0) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Enable working-state continuity across parent/child agent boundaries — parent seeds child with structured context, child recovers unsurfaced results back to parent.
|
||||||
|
|
||||||
|
**Architecture:** Two new lifecycle hooks added to the existing plugin. (1) Enhance `before_agent_start` to detect forked child sessions and inject the parent's CURRENT_STATE.md as a structured seed. (2) Add a new `subagent_ended` hook that reads the child's workspace CURRENT_STATE.md and merges unsurfaced results into the parent's state. Both features are conservative — they degrade gracefully when context is unavailable and never overwrite parent state destructively.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM, OpenClaw plugin lifecycle hooks (`before_agent_start`, `subagent_ended`), filesystem-based state passing (cross-workspace read), zero external dependencies.
|
||||||
|
|
||||||
|
**Research Findings (constraints):**
|
||||||
|
- `subagent_ended` event provides: `{ targetSessionKey, reason, outcome, error }` and context `{ childSessionKey, requesterSessionKey }`
|
||||||
|
- No pre-spawn hook exists — parent seeding happens via `before_agent_start` on the child side
|
||||||
|
- Workspaces are fully isolated — each agent has its own `memory/` directory
|
||||||
|
- Session key format: `agent:<agentId>:subagent:<uuid>` — parseable to extract `agentId`
|
||||||
|
- Agent workspace is resolvable via config: `agents.list[].workspace` or default `~/.openclaw/workspace/main`
|
||||||
|
- `isSubagentSessionKey()` and `parseAgentSessionKey()` are runtime utilities (not available to plugins directly — must parse manually)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Modify | `index.js` | Add parent-seed logic to `before_agent_start`, add new `subagent_ended` hook, add helper functions |
|
||||||
|
| Modify | `openclaw.plugin.json` | Bump to 5.0.0, add new config keys |
|
||||||
|
| Modify | `package.json` | Bump to 5.0.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Workspace Resolution Helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (helpers section, before plugin definition)
|
||||||
|
|
||||||
|
Two new helper functions are needed: one to detect if the current session is a subagent, and one to resolve a workspace directory from an agent ID.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `isSubagentSession` helper**
|
||||||
|
|
||||||
|
In `index.js`, after the existing `extractStateFromMessages` function (around line 709) and before the `// Plugin Definition` comment, add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Detect if the current session is a subagent by checking the session key format.
|
||||||
|
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
|
||||||
|
*/
|
||||||
|
function isSubagentSession(ctx) {
|
||||||
|
const key = ctx?.sessionKey || ctx?.SessionKey || "";
|
||||||
|
return /^agent:[^:]+:subagent:/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the parent agent ID from a subagent session key.
|
||||||
|
* Session key format: agent:<parentAgentId>:subagent:<uuid>
|
||||||
|
* Returns null if not a subagent key.
|
||||||
|
*/
|
||||||
|
function parseParentAgentId(sessionKey) {
|
||||||
|
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
|
||||||
|
return match?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve workspace directory for a given agent ID.
|
||||||
|
* Searches the OpenClaw config for agent workspace mappings.
|
||||||
|
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
|
||||||
|
* or ~/.openclaw/workspaces/<agentId> for named agents.
|
||||||
|
*/
|
||||||
|
function resolveAgentWorkspace(agentId) {
|
||||||
|
if (!agentId) return null;
|
||||||
|
|
||||||
|
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
|
||||||
|
|
||||||
|
// Try to read config for explicit workspace mapping
|
||||||
|
try {
|
||||||
|
const configPath = path.join(base, "openclaw.json");
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
const agents = config?.agents?.list || [];
|
||||||
|
const entry = agents.find(a => a.id === agentId || a.name === agentId);
|
||||||
|
if (entry?.workspace) return entry.workspace;
|
||||||
|
|
||||||
|
// Check defaults
|
||||||
|
if (agentId === "main") {
|
||||||
|
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fallback heuristics
|
||||||
|
if (agentId === "main") return path.join(base, "workspace", "main");
|
||||||
|
|
||||||
|
// Named agents typically use ~/.openclaw/workspaces/<agentId>
|
||||||
|
const namedWs = path.join(base, "workspaces", agentId);
|
||||||
|
if (fs.existsSync(namedWs)) return namedWs;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Unsurfaced Results section from a state markdown file.
|
||||||
|
* Returns the raw text content of the section, or null if empty/placeholder.
|
||||||
|
*/
|
||||||
|
function extractUnsurfacedResults(md) {
|
||||||
|
if (!md) return null;
|
||||||
|
const section = extractSection(md, "Unsurfaced Results");
|
||||||
|
if (!section || !isMeaningful(section)) return null;
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file still parses**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: object`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat(subagent): add workspace resolution and session detection helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Parent→Child Seeding in before_agent_start
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (HOOK 1: before_agent_start)
|
||||||
|
|
||||||
|
When a child agent starts (detected via subagent session key format), read the parent agent's CURRENT_STATE.md and inject it as additional context alongside the child's own state.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Enhance the `before_agent_start` hook**
|
||||||
|
|
||||||
|
In `index.js`, find HOOK 1 (`before_agent_start`). The current code reads the agent's own state and injects it. After the existing relevance injection block (around the `if (config.relevanceInjection !== false)` block), add parent-seed logic:
|
||||||
|
|
||||||
|
Find this existing code block:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Relevance injection: find related historical entries
|
||||||
|
if (config.relevanceInjection !== false) {
|
||||||
|
const objective = extractSection(md, "Objective");
|
||||||
|
const maxItems = config.maxRelevanceItems ?? 3;
|
||||||
|
const history = findRelevantHistory(ws, objective, maxItems);
|
||||||
|
if (history) {
|
||||||
|
parts.push(history);
|
||||||
|
log.info?.("[memory-continuity] Injected relevant history context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Right after this block (before the `return` statement), insert:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Parent seed: if this is a subagent, inject parent's working state
|
||||||
|
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
|
||||||
|
try {
|
||||||
|
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
|
||||||
|
const parentAgentId = parseParentAgentId(sessionKey);
|
||||||
|
if (parentAgentId) {
|
||||||
|
const parentWs = resolveAgentWorkspace(parentAgentId);
|
||||||
|
if (parentWs) {
|
||||||
|
const parentStatePath = resolveStatePath(parentWs);
|
||||||
|
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
|
||||||
|
if (parentMd) {
|
||||||
|
const parentSnapshot = buildSnapshot(parentMd);
|
||||||
|
if (parentSnapshot) {
|
||||||
|
parts.push(
|
||||||
|
"=== PARENT AGENT CONTEXT ===\n" +
|
||||||
|
"The following is the parent agent's working state. " +
|
||||||
|
"Use this to understand the broader task context.\n" +
|
||||||
|
parentSnapshot +
|
||||||
|
"\n=== END PARENT CONTEXT ==="
|
||||||
|
);
|
||||||
|
log.info?.("[memory-continuity] Injected parent state seed for subagent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify plugin loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: object`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat(subagent): parent-to-child state seeding in before_agent_start"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Child→Parent Recovery via subagent_ended Hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.js` (add new HOOK 6: subagent_ended)
|
||||||
|
|
||||||
|
When a child agent ends, read its CURRENT_STATE.md and check for unsurfaced results. If found, append them to the parent's state file.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `subagent_ended` hook**
|
||||||
|
|
||||||
|
In `index.js`, find the section after HOOK 5 (`session_end`) and before the SERVICE section. Add a new hook between them:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// HOOK 6: subagent_ended — recover child's unsurfaced results
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
api.on("subagent_ended", async (event, _ctx) => {
|
||||||
|
const config = getConfig();
|
||||||
|
if (config.subagentRecovery === false) return;
|
||||||
|
|
||||||
|
const parentWs = _ctx?.workspaceDir;
|
||||||
|
if (!parentWs) return;
|
||||||
|
|
||||||
|
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
|
||||||
|
if (!childSessionKey) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process successful completions (not kills/errors)
|
||||||
|
const outcome = event?.outcome || "";
|
||||||
|
const reason = event?.reason || "";
|
||||||
|
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse child agent ID from session key
|
||||||
|
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
|
||||||
|
const childAgentId = childMatch?.[1];
|
||||||
|
if (!childAgentId) return;
|
||||||
|
|
||||||
|
const childWs = resolveAgentWorkspace(childAgentId);
|
||||||
|
if (!childWs) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read child's state
|
||||||
|
const childStatePath = resolveStatePath(childWs);
|
||||||
|
const childMd = childStatePath ? readFile(childStatePath) : null;
|
||||||
|
if (!childMd) return;
|
||||||
|
|
||||||
|
// Extract unsurfaced results from child
|
||||||
|
const childUnsurfaced = extractUnsurfacedResults(childMd);
|
||||||
|
if (!childUnsurfaced) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also extract child's objective for context
|
||||||
|
const childObjective = extractSection(childMd, "Objective");
|
||||||
|
|
||||||
|
// Read parent's current state
|
||||||
|
const parentStatePath = resolveStatePath(parentWs);
|
||||||
|
if (!parentStatePath) return;
|
||||||
|
|
||||||
|
let parentMd = readFile(parentStatePath);
|
||||||
|
if (!parentMd) {
|
||||||
|
parentMd = STATE_TEMPLATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the recovery note
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const recoveryNote = [
|
||||||
|
`[${now}] Subagent "${childAgentId}" completed.`,
|
||||||
|
childObjective ? ` Task: ${childObjective.split("\n")[0].slice(0, 120)}` : "",
|
||||||
|
` Result: ${childUnsurfaced.split("\n")[0].slice(0, 200)}`,
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
// Merge into parent's Unsurfaced Results section
|
||||||
|
const existingUnsurfaced = extractSection(parentMd, "Unsurfaced Results");
|
||||||
|
const mergedUnsurfaced = isMeaningful(existingUnsurfaced)
|
||||||
|
? existingUnsurfaced + "\n" + recoveryNote
|
||||||
|
: recoveryNote;
|
||||||
|
|
||||||
|
// Token-aware truncation of merged results
|
||||||
|
if (estimateTokens(mergedUnsurfaced) > 500) {
|
||||||
|
// Keep only the most recent entries (last 500 tokens)
|
||||||
|
const lines = mergedUnsurfaced.split("\n");
|
||||||
|
let kept = [];
|
||||||
|
let tokens = 0;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const lineTokens = estimateTokens(lines[i]);
|
||||||
|
if (tokens + lineTokens > 500) break;
|
||||||
|
kept.unshift(lines[i]);
|
||||||
|
tokens += lineTokens;
|
||||||
|
}
|
||||||
|
const truncated = kept.join("\n");
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
||||||
|
"## Unsurfaced Results\n" + truncated
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
||||||
|
"## Unsurfaced Results\n" + mergedUnsurfaced
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the timestamp
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/^> Last updated:.*$/m,
|
||||||
|
`> Last updated: ${now}`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFile(parentStatePath, parentMd);
|
||||||
|
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
|
||||||
|
}
|
||||||
|
}, { priority: 50 });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify plugin loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: object`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add index.js
|
||||||
|
git commit -m "feat(subagent): child-to-parent unsurfaced results recovery via subagent_ended"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: New Config Keys + Manifest Bump
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `openclaw.plugin.json`
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add subagent config keys to `openclaw.plugin.json`**
|
||||||
|
|
||||||
|
Bump version to `5.0.0`. Add two new config properties inside `configSchema.properties`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"subagentSeed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Inject parent working state into subagent context at startup"
|
||||||
|
},
|
||||||
|
"subagentRecovery": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Recover unsurfaced results from completed subagents back to parent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add corresponding `uiHints`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"subagentSeed": {
|
||||||
|
"label": "Subagent seeding",
|
||||||
|
"help": "Give subagents parent context so they understand the broader task"
|
||||||
|
},
|
||||||
|
"subagentRecovery": {
|
||||||
|
"label": "Subagent recovery",
|
||||||
|
"help": "Pull completed subagent results back into parent state"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bump `package.json` to 5.0.0**
|
||||||
|
|
||||||
|
Change `"version": "4.0.0"` to `"version": "5.0.0"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify JSON validity**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
||||||
|
python3 -m json.tool openclaw.plugin.json > /dev/null && echo "JSON valid"
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
JSON valid
|
||||||
|
OK: object
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add openclaw.plugin.json package.json
|
||||||
|
git commit -m "feat(subagent): add subagentSeed and subagentRecovery config keys, bump to v5.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add `/mc subagents` Command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mc-plugin/index.js`
|
||||||
|
- Modify: `mc-plugin/package.json`
|
||||||
|
- Modify: `mc-plugin/openclaw.plugin.json`
|
||||||
|
|
||||||
|
Add a new `/mc subagents` command that shows subagent state across workspaces.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `cmdSubagents` function to mc-plugin**
|
||||||
|
|
||||||
|
In `mc-plugin/index.js`, before the `cmdHelp` function, add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function cmdSubagents(args) {
|
||||||
|
const agents = discoverAgents();
|
||||||
|
if (agents.length === 0) return "No agents with memory found.";
|
||||||
|
|
||||||
|
let out = "Subagent State Overview\n";
|
||||||
|
out += "═══════════════════════\n\n";
|
||||||
|
|
||||||
|
for (const { name, memDir } of agents) {
|
||||||
|
const statePath = path.join(memDir, "CURRENT_STATE.md");
|
||||||
|
const content = readFile(statePath);
|
||||||
|
if (!content) {
|
||||||
|
out += `[${name}] No state file\n\n`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
|
||||||
|
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
|
||||||
|
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
|
||||||
|
|
||||||
|
const objective = objMatch?.[1]?.trim() || "None";
|
||||||
|
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
|
||||||
|
const updated = updatedMatch?.[1]?.trim() || "unknown";
|
||||||
|
|
||||||
|
out += `[${name}] Updated: ${updated}\n`;
|
||||||
|
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
|
||||||
|
if (unsurfaced !== "None") {
|
||||||
|
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
|
||||||
|
}
|
||||||
|
out += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the command in the switch statement**
|
||||||
|
|
||||||
|
Find the `switch (subcmd)` block and add a new case before the `help` case:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
case "subagents": text = cmdSubagents(subargs || null); break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update help text**
|
||||||
|
|
||||||
|
In `cmdHelp`, add:
|
||||||
|
|
||||||
|
```
|
||||||
|
/mc subagents Subagent state overview across workspaces
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bump mc-plugin versions to 2.1.0**
|
||||||
|
|
||||||
|
In `mc-plugin/package.json`, change version to `"2.1.0"`.
|
||||||
|
In `mc-plugin/openclaw.plugin.json`, change version to `"2.1.0"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify mc-plugin loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/taodeng/.openclaw/projects/memory-continuity/mc-plugin
|
||||||
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK: function`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mc-plugin/
|
||||||
|
git commit -m "feat(subagent): add /mc subagents command for cross-workspace state view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Version Verify + Git + Deploy
|
||||||
|
|
||||||
|
**Files:** All modified files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify all versions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
openclaw.plugin.json: "version": "5.0.0"
|
||||||
|
package.json: "version": "5.0.0"
|
||||||
|
mc-plugin/openclaw.plugin.json: "version": "2.1.0"
|
||||||
|
mc-plugin/package.json: "version": "2.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Final commit + tag + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "release: v5.0.0 — conservative subagent support (parent seed + child recovery)"
|
||||||
|
git tag v5.0.0
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy to Cloud**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp index.js package.json openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
|
||||||
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
|
||||||
|
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Deploy to PI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp index.js package.json openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
|
||||||
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
|
||||||
|
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Deploy to Mac**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
|
||||||
|
pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify all 3 servers**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep version ~/.openclaw/extensions/memory-continuity/package.json
|
||||||
|
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
||||||
|
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all show `"version": "5.0.0"`.
|
||||||
@@ -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}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "mc-plugin",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["openclaw", "plugin", "memory", "continuity", "commands"],
|
||||||
|
"license": "MIT",
|
||||||
|
"openclaw": {
|
||||||
|
"type": "plugin",
|
||||||
|
"id": "mc",
|
||||||
|
"pluginManifest": "openclaw.plugin.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-5
@@ -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.3.0",
|
"version": "5.0.1",
|
||||||
|
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/dtzp555-max/memory-continuity",
|
||||||
|
"category": "memory",
|
||||||
|
"tags": ["memory", "continuity", "state", "recovery", "markdown", "zero-dependency"],
|
||||||
|
"icon": "brain",
|
||||||
|
"minOpenClawVersion": "2026.3.0",
|
||||||
"source": "https://github.com/dtzp555-max/memory-continuity",
|
"source": "https://github.com/dtzp555-max/memory-continuity",
|
||||||
|
"interop": {
|
||||||
|
"complements": [
|
||||||
|
{
|
||||||
|
"id": "lossless-claw",
|
||||||
|
"reason": "MC uses lifecycle hooks for working-state recovery; lossless-claw uses the contextEngine slot for lossless context compression. They occupy different plugin slots and serve different purposes — install both for best results."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conflicts": [],
|
||||||
|
"slot": "hooks-only",
|
||||||
|
"slotNote": "Does NOT occupy the contextEngine slot. Safe to run alongside any context engine."
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -22,12 +40,111 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Auto-extract working state from conversation at agent_end"
|
"description": "Auto-extract working state from conversation at agent_end"
|
||||||
|
},
|
||||||
|
"maxArchiveCount": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 20,
|
||||||
|
"description": "Maximum number of archive files to keep in session_archive/"
|
||||||
|
},
|
||||||
|
"ignorePatterns": {
|
||||||
|
"type": "array",
|
||||||
|
"default": [],
|
||||||
|
"description": "Regex patterns to ignore sessions (e.g. cron jobs, subagent noise). Matched against first user message."
|
||||||
|
},
|
||||||
|
"sessionLogging": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Write session summaries to memory/sessions/ daily logs"
|
||||||
|
},
|
||||||
|
"tailProtectCount": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 3,
|
||||||
|
"description": "Number of recent critical message pairs to protect during compaction"
|
||||||
|
},
|
||||||
|
"summaryEnabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generate daily and weekly summaries from session logs"
|
||||||
|
},
|
||||||
|
"relevanceInjection": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Inject relevant historical context at session start"
|
||||||
|
},
|
||||||
|
"maxRelevanceItems": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 3,
|
||||||
|
"description": "Maximum number of relevant history items to inject"
|
||||||
|
},
|
||||||
|
"archiveDecayDays": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 30,
|
||||||
|
"description": "Days before archives move to cold storage (0 = disabled)"
|
||||||
|
},
|
||||||
|
"subagentSeed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Inject parent working state into subagent context at startup"
|
||||||
|
},
|
||||||
|
"subagentRecovery": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Recover unsurfaced results from completed subagents back to parent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uiHints": {
|
"uiHints": {
|
||||||
"maxStateLines": { "label": "Max state file lines", "help": "Keep checkpoint small for fast recovery" },
|
"maxStateLines": {
|
||||||
"archiveOnNew": { "label": "Archive on /new", "help": "Save a snapshot before session reset" },
|
"label": "Max state file lines",
|
||||||
"autoExtract": { "label": "Auto-extract state", "help": "Automatically save working state when conversation ends" }
|
"help": "Keep checkpoint small for fast recovery"
|
||||||
|
},
|
||||||
|
"archiveOnNew": {
|
||||||
|
"label": "Archive on /new",
|
||||||
|
"help": "Save a snapshot before session reset"
|
||||||
|
},
|
||||||
|
"autoExtract": {
|
||||||
|
"label": "Auto-extract state",
|
||||||
|
"help": "Automatically save working state when conversation ends"
|
||||||
|
},
|
||||||
|
"maxArchiveCount": {
|
||||||
|
"label": "Max archive files",
|
||||||
|
"help": "Old archives are auto-deleted when this limit is reached"
|
||||||
|
},
|
||||||
|
"ignorePatterns": {
|
||||||
|
"label": "Ignore patterns",
|
||||||
|
"help": "Skip state extraction for sessions matching these patterns (regex)"
|
||||||
|
},
|
||||||
|
"sessionLogging": {
|
||||||
|
"label": "Session logging",
|
||||||
|
"help": "Append session summaries to daily markdown logs"
|
||||||
|
},
|
||||||
|
"tailProtectCount": {
|
||||||
|
"label": "Tail protect count",
|
||||||
|
"help": "Keep N recent user/assistant pairs visible after compaction"
|
||||||
|
},
|
||||||
|
"summaryEnabled": {
|
||||||
|
"label": "Summary generation",
|
||||||
|
"help": "Auto-generate daily/weekly summaries from session logs"
|
||||||
|
},
|
||||||
|
"relevanceInjection": {
|
||||||
|
"label": "Relevance injection",
|
||||||
|
"help": "Inject related history when starting a new session"
|
||||||
|
},
|
||||||
|
"maxRelevanceItems": {
|
||||||
|
"label": "Max relevance items",
|
||||||
|
"help": "How many related history entries to inject (2-5)"
|
||||||
|
},
|
||||||
|
"archiveDecayDays": {
|
||||||
|
"label": "Archive decay days",
|
||||||
|
"help": "Move archives older than N days to cold/ subfolder (0 to disable)"
|
||||||
|
},
|
||||||
|
"subagentSeed": {
|
||||||
|
"label": "Subagent seeding",
|
||||||
|
"help": "Give subagents parent context so they understand the broader task"
|
||||||
|
},
|
||||||
|
"subagentRecovery": {
|
||||||
|
"label": "Subagent recovery",
|
||||||
|
"help": "Pull completed subagent results back into parent state"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "memory-continuity",
|
"name": "memory-continuity",
|
||||||
"version": "2.3.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": {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -15,6 +15,7 @@ import sys
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -32,7 +33,7 @@ class Severity:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class DiagnosticReport:
|
class DiagnosticReport:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.entries: list[tuple[str, str]] = []
|
self.entries: List[Tuple[str, str]] = []
|
||||||
self._worst = Severity.OK
|
self._worst = Severity.OK
|
||||||
|
|
||||||
def add(self, severity: str, message: str):
|
def add(self, severity: str, message: str):
|
||||||
@@ -78,7 +79,7 @@ PLACEHOLDER_PATTERNS = [
|
|||||||
# Checks
|
# Checks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def check_existence(workspace: Path, report: DiagnosticReport) -> Path | None:
|
def check_existence(workspace: Path, report: DiagnosticReport) -> Optional[Path]:
|
||||||
"""Check that memory/CURRENT_STATE.md exists."""
|
"""Check that memory/CURRENT_STATE.md exists."""
|
||||||
state_file = workspace / "memory" / "CURRENT_STATE.md"
|
state_file = workspace / "memory" / "CURRENT_STATE.md"
|
||||||
if not state_file.exists():
|
if not state_file.exists():
|
||||||
@@ -111,7 +112,7 @@ def check_staleness(state_file: Path, workspace: Path, report: DiagnosticReport)
|
|||||||
def check_template_compliance(state_file: Path, report: DiagnosticReport) -> dict:
|
def check_template_compliance(state_file: Path, report: DiagnosticReport) -> dict:
|
||||||
"""Check all required sections are present and not placeholder-only."""
|
"""Check all required sections are present and not placeholder-only."""
|
||||||
content = state_file.read_text(encoding="utf-8")
|
content = state_file.read_text(encoding="utf-8")
|
||||||
sections_found: dict[str, str] = {}
|
sections_found: Dict[str, str] = {}
|
||||||
|
|
||||||
for section in REQUIRED_SECTIONS:
|
for section in REQUIRED_SECTIONS:
|
||||||
# Match ## Section or ## Section\n
|
# Match ## Section or ## Section\n
|
||||||
|
|||||||
+192
-14
@@ -4,7 +4,7 @@
|
|||||||
# This script:
|
# This script:
|
||||||
# 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/
|
# 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/
|
||||||
# 2. Adds the plugin entry to openclaw.json (if not present)
|
# 2. Adds the plugin entry to openclaw.json (if not present)
|
||||||
# 3. Restarts the gateway
|
# 3. Detects OpenClaw agents and installs SKILL.md to selected workspaces
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash scripts/post-install.sh
|
# bash scripts/post-install.sh
|
||||||
@@ -50,16 +50,13 @@ if [[ ! -f "$CONFIG_FILE" ]]; then
|
|||||||
echo " WARNING: $CONFIG_FILE not found. Skipping config update."
|
echo " WARNING: $CONFIG_FILE not found. Skipping config update."
|
||||||
echo " You'll need to manually add the plugin entry."
|
echo " You'll need to manually add the plugin entry."
|
||||||
else
|
else
|
||||||
# Check if memory-continuity entry already exists
|
python3 -c "
|
||||||
# Always ensure allow list, install record, and entry are present (idempotent)
|
|
||||||
python3 -c "
|
|
||||||
import json
|
import json
|
||||||
path = '$CONFIG_FILE'
|
path = '$CONFIG_FILE'
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if 'plugins' not in data:
|
if 'plugins' not in data:
|
||||||
data['plugins'] = {}
|
data['plugins'] = {}
|
||||||
# Add to plugins.allow so OpenClaw trusts this plugin (no provenance warning)
|
|
||||||
allow_list = data['plugins'].get('allow', [])
|
allow_list = data['plugins'].get('allow', [])
|
||||||
if 'memory-continuity' not in allow_list:
|
if 'memory-continuity' not in allow_list:
|
||||||
allow_list.append('memory-continuity')
|
allow_list.append('memory-continuity')
|
||||||
@@ -77,7 +74,6 @@ data['plugins']['entries']['memory-continuity'] = {
|
|||||||
'autoExtract': True
|
'autoExtract': True
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# Add install record for provenance tracking (eliminates untracked-code warning)
|
|
||||||
if 'installs' not in data['plugins']:
|
if 'installs' not in data['plugins']:
|
||||||
data['plugins']['installs'] = {}
|
data['plugins']['installs'] = {}
|
||||||
data['plugins']['installs']['memory-continuity'] = {
|
data['plugins']['installs']['memory-continuity'] = {
|
||||||
@@ -91,23 +87,205 @@ print(' Added plugin entry, trust config, and install record')
|
|||||||
" 2>/dev/null || echo " WARNING: Could not update config automatically. Add manually."
|
" 2>/dev/null || echo " WARNING: Could not update config automatically. Add manually."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 3: Restart gateway
|
# ---------------------------------------------------------------------------
|
||||||
echo "[3/3] Restarting gateway ..."
|
# Step 3: Detect agents and install SKILL.md to selected workspaces
|
||||||
if command -v openclaw &>/dev/null; then
|
# ---------------------------------------------------------------------------
|
||||||
if openclaw gateway status 2>&1 | grep -q "running"; then
|
echo "[3/3] Detecting OpenClaw agents ..."
|
||||||
openclaw gateway restart 2>/dev/null && echo " Gateway restarted" || echo " Gateway restart failed — try: openclaw gateway restart"
|
|
||||||
|
GRAY='\033[0;90m'
|
||||||
|
RST='\033[0m'
|
||||||
|
|
||||||
|
# detect_agents outputs lines of: INDEX|ID|DISPLAY_NAME|WORKSPACE_PATH
|
||||||
|
# Uses python3 to parse openclaw.json; falls back gracefully if unavailable.
|
||||||
|
detect_agents() {
|
||||||
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
|
||||||
|
import json, sys, os
|
||||||
|
|
||||||
|
config_file = sys.argv[1]
|
||||||
|
openclaw_dir = sys.argv[2]
|
||||||
|
|
||||||
|
with open(config_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', os.path.join(openclaw_dir, 'workspace', 'main'))
|
||||||
|
|
||||||
|
agents = data.get('agents', {}).get('list', [])
|
||||||
|
seen = set()
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
agent_id = agent.get('id', '')
|
||||||
|
if not agent_id or agent_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(agent_id)
|
||||||
|
|
||||||
|
name = agent.get('name', agent_id)
|
||||||
|
workspace = agent.get('workspace', default_ws)
|
||||||
|
|
||||||
|
# Expand ~ in path
|
||||||
|
workspace = os.path.expanduser(workspace)
|
||||||
|
|
||||||
|
print('{}|{}|{}|{}'.format(idx, agent_id, name, workspace))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect ALL detected agents (including non-alive) into arrays
|
||||||
|
ALL_AGENT_IDS=()
|
||||||
|
ALL_AGENT_NAMES=()
|
||||||
|
ALL_AGENT_WORKSPACES=()
|
||||||
|
|
||||||
|
if command -v python3 &>/dev/null && [[ -f "$CONFIG_FILE" ]]; then
|
||||||
|
while IFS='|' read -r idx agent_id agent_name workspace; do
|
||||||
|
ALL_AGENT_IDS+=("$agent_id")
|
||||||
|
ALL_AGENT_NAMES+=("$agent_name")
|
||||||
|
ALL_AGENT_WORKSPACES+=("$workspace")
|
||||||
|
done < <(detect_agents 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Filter to alive agents (workspace directory exists)
|
||||||
|
AGENT_IDS=()
|
||||||
|
AGENT_NAMES=()
|
||||||
|
AGENT_WORKSPACES=()
|
||||||
|
SKIPPED=0
|
||||||
|
|
||||||
|
for i in "${!ALL_AGENT_IDS[@]}"; do
|
||||||
|
if [[ -d "${ALL_AGENT_WORKSPACES[$i]}" ]]; then
|
||||||
|
AGENT_IDS+=("${ALL_AGENT_IDS[$i]}")
|
||||||
|
AGENT_NAMES+=("${ALL_AGENT_NAMES[$i]}")
|
||||||
|
AGENT_WORKSPACES+=("${ALL_AGENT_WORKSPACES[$i]}")
|
||||||
else
|
else
|
||||||
echo " Gateway not running — start it with: openclaw gateway start"
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
install_skill_to_workspace() {
|
||||||
|
local workspace="$1"
|
||||||
|
local skill_dest="${workspace}/skills/memory-continuity"
|
||||||
|
mkdir -p "$skill_dest"
|
||||||
|
cp "$REPO_DIR/SKILL.md" "$skill_dest/SKILL.md"
|
||||||
|
echo " Installed → ${skill_dest}/SKILL.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ${#AGENT_IDS[@]} -eq 0 && ${#ALL_AGENT_IDS[@]} -gt 0 ]]; then
|
||||||
|
# Config has agents but none are alive
|
||||||
|
echo ""
|
||||||
|
echo " Found ${#ALL_AGENT_IDS[@]} agent(s) in config but their workspace directories don't exist yet."
|
||||||
|
echo " You may need to initialize them first."
|
||||||
|
echo ""
|
||||||
|
printf " Enter workspace path to install SKILL.md (or press Enter to skip): "
|
||||||
|
read -r FALLBACK_WS
|
||||||
|
if [[ -n "$FALLBACK_WS" ]]; then
|
||||||
|
FALLBACK_WS="${FALLBACK_WS/#\~/$HOME}"
|
||||||
|
install_skill_to_workspace "$FALLBACK_WS"
|
||||||
|
else
|
||||||
|
echo " Skipped SKILL.md installation."
|
||||||
|
fi
|
||||||
|
elif [[ ${#AGENT_IDS[@]} -eq 0 ]]; then
|
||||||
|
# No agents at all — ask user for a workspace path
|
||||||
|
echo " No agents detected in openclaw.json (file missing or parse error)."
|
||||||
|
echo ""
|
||||||
|
printf " Enter workspace path to install SKILL.md (or press Enter to skip): "
|
||||||
|
read -r FALLBACK_WS
|
||||||
|
if [[ -n "$FALLBACK_WS" ]]; then
|
||||||
|
FALLBACK_WS="${FALLBACK_WS/#\~/$HOME}"
|
||||||
|
install_skill_to_workspace "$FALLBACK_WS"
|
||||||
|
else
|
||||||
|
echo " Skipped SKILL.md installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " openclaw command not found — install OpenClaw first"
|
# Show numbered list — alive agents first, then skipped summary
|
||||||
|
echo ""
|
||||||
|
echo " Found ${#AGENT_IDS[@]} alive agent(s):"
|
||||||
|
for i in "${!AGENT_IDS[@]}"; do
|
||||||
|
num=$((i + 1))
|
||||||
|
printf " [%d] %s (%s)\n" "$num" "${AGENT_NAMES[$i]}" "${AGENT_WORKSPACES[$i]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Show skipped (non-alive) agents in gray
|
||||||
|
if [[ $SKIPPED -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
printf " ${GRAY}Skipped %d agent(s) with missing workspace directories:${RST}\n" "$SKIPPED"
|
||||||
|
for i in "${!ALL_AGENT_IDS[@]}"; do
|
||||||
|
if [[ ! -d "${ALL_AGENT_WORKSPACES[$i]}" ]]; then
|
||||||
|
printf " ${GRAY} %s (%s)${RST}\n" "${ALL_AGENT_NAMES[$i]}" "${ALL_AGENT_WORKSPACES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Install to: [A]ll alive agents (default) / [1,2,...] specific / [Q]uit"
|
||||||
|
printf " > (20s timeout, Enter or no input = All) "
|
||||||
|
|
||||||
|
# Auto-select All if stdin is not a tty (pipe/CI) or on timeout
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
SELECTION="A"
|
||||||
|
echo "(non-interactive: auto-selecting All)"
|
||||||
|
elif read -t 20 -r SELECTION; then
|
||||||
|
# User provided input (possibly empty = Enter)
|
||||||
|
: # SELECTION is set
|
||||||
|
else
|
||||||
|
# Timeout — default to All
|
||||||
|
echo ""
|
||||||
|
echo " (timeout — defaulting to All)"
|
||||||
|
SELECTION=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Empty input (Enter or timeout) defaults to All
|
||||||
|
if [[ -z "$SELECTION" ]]; then
|
||||||
|
SELECTION="A"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SELECTION_UPPER="$(echo "$SELECTION" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
case "$SELECTION_UPPER" in
|
||||||
|
A|ALL)
|
||||||
|
for i in "${!AGENT_IDS[@]}"; do
|
||||||
|
install_skill_to_workspace "${AGENT_WORKSPACES[$i]}"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
Q|QUIT)
|
||||||
|
echo " Skipped SKILL.md installation."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Parse comma-separated numbers
|
||||||
|
IFS=',' read -ra PICKS <<< "$SELECTION"
|
||||||
|
INSTALLED=0
|
||||||
|
for pick in "${PICKS[@]}"; do
|
||||||
|
pick="${pick// /}" # trim spaces
|
||||||
|
if [[ "$pick" =~ ^[0-9]+$ ]]; then
|
||||||
|
idx=$((pick - 1))
|
||||||
|
if [[ $idx -ge 0 && $idx -lt ${#AGENT_IDS[@]} ]]; then
|
||||||
|
install_skill_to_workspace "${AGENT_WORKSPACES[$idx]}"
|
||||||
|
INSTALLED=$((INSTALLED + 1))
|
||||||
|
else
|
||||||
|
echo " WARNING: No agent at index $pick — skipped."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " WARNING: Invalid selection '$pick' — skipped."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $INSTALLED -eq 0 ]]; then
|
||||||
|
echo " No valid selections — SKILL.md not installed."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🧙 启动配置向导..."
|
||||||
|
python3 "$(dirname "$0")/setup_wizard.py" || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Installation complete ==="
|
echo "=== Installation complete ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Verify with:"
|
echo "Verify with:"
|
||||||
echo " openclaw gateway restart 2>&1 | grep memory-continuity"
|
echo " bash scripts/verify.sh"
|
||||||
|
echo " bash scripts/verify.sh --all-agents"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test with:"
|
echo "Test with:"
|
||||||
echo " 1. Tell your agent something memorable"
|
echo " 1. Tell your agent something memorable"
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# setup_wizard.py — Interactive post-install wizard for memory-continuity skill
|
||||||
|
#
|
||||||
|
# Guides the user through:
|
||||||
|
# Step 1/4: Select default model
|
||||||
|
# Step 2/4: Configure per-agent models
|
||||||
|
# Step 3/4: Review changes + OCP check
|
||||||
|
# Step 4/4: Restart gateway
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# python3 scripts/setup_wizard.py
|
||||||
|
#
|
||||||
|
# Requires Python 3.6+
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
import signal
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Terminal colors (ANSI)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
YELLOW = "\033[33m"
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
RED = "\033[31m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
def green(s): return f"{GREEN}{s}{RESET}"
|
||||||
|
def yellow(s): return f"{YELLOW}{s}{RESET}"
|
||||||
|
def cyan(s): return f"{CYAN}{s}{RESET}"
|
||||||
|
def red(s): return f"{RED}{s}{RESET}"
|
||||||
|
def bold(s): return f"{BOLD}{s}{RESET}"
|
||||||
|
def dim(s): return f"{DIM}{s}{RESET}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Key reading (raw terminal, no curses dependency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
UP_ARROW = "UP"
|
||||||
|
DOWN_ARROW = "DOWN"
|
||||||
|
ENTER_KEY = "ENTER"
|
||||||
|
CTRL_C = "CTRL_C"
|
||||||
|
|
||||||
|
def _read_key():
|
||||||
|
"""Read a single keypress from stdin. Returns a key name string."""
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setraw(fd)
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return CTRL_C
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return ENTER_KEY
|
||||||
|
if ch == "\x1b":
|
||||||
|
# Escape sequence — read more
|
||||||
|
ch2 = sys.stdin.read(1)
|
||||||
|
if ch2 == "[":
|
||||||
|
ch3 = sys.stdin.read(1)
|
||||||
|
if ch3 == "A":
|
||||||
|
return UP_ARROW
|
||||||
|
if ch3 == "B":
|
||||||
|
return DOWN_ARROW
|
||||||
|
return ch
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clean exit on Ctrl+C anywhere
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _handle_sigint(sig, frame):
|
||||||
|
print(f"\n\n{yellow('已取消,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _handle_sigint)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arrow-key menu selector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def arrow_select(prompt, options, default_index=0):
|
||||||
|
"""
|
||||||
|
Display a list of options with arrow-key navigation.
|
||||||
|
Returns the selected index, or raises SystemExit on Ctrl+C.
|
||||||
|
"""
|
||||||
|
selected = default_index
|
||||||
|
n = len(options)
|
||||||
|
|
||||||
|
# Hide cursor
|
||||||
|
sys.stdout.write("\033[?25l")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def _render():
|
||||||
|
# Move cursor up to re-draw
|
||||||
|
sys.stdout.write(f"\033[{n}A\r")
|
||||||
|
for i, opt in enumerate(options):
|
||||||
|
if i == selected:
|
||||||
|
prefix = f" {CYAN}▶ {BOLD}"
|
||||||
|
suffix = RESET
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
suffix = ""
|
||||||
|
sys.stdout.write(f"\r{prefix}{opt}{suffix}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# Initial render
|
||||||
|
if prompt:
|
||||||
|
print(prompt)
|
||||||
|
for i, opt in enumerate(options):
|
||||||
|
print(f" {opt}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_render()
|
||||||
|
key = _read_key()
|
||||||
|
if key == CTRL_C:
|
||||||
|
sys.stdout.write("\033[?25h")
|
||||||
|
sys.stdout.flush()
|
||||||
|
print(f"\n\n{yellow('已取消,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
elif key == UP_ARROW:
|
||||||
|
selected = (selected - 1) % n
|
||||||
|
elif key == DOWN_ARROW:
|
||||||
|
selected = (selected + 1) % n
|
||||||
|
elif key == ENTER_KEY:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
sys.stdout.write("\033[?25h")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Yes/No prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def yes_no(prompt, default_yes=True):
|
||||||
|
"""Ask a yes/no question. Returns True for yes."""
|
||||||
|
hint = "[Y/n]" if default_yes else "[y/N]"
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ans = input(f"{prompt} {hint}: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print(f"\n\n{yellow('已取消,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
if ans == "":
|
||||||
|
return default_yes
|
||||||
|
if ans in ("y", "yes"):
|
||||||
|
return True
|
||||||
|
if ans in ("n", "no"):
|
||||||
|
return False
|
||||||
|
print(yellow(" 请输入 Y 或 N"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Progress header
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def print_step(n, total, title):
|
||||||
|
print()
|
||||||
|
print(bold(cyan(f"步骤 {n}/{total}: {title}")))
|
||||||
|
print(cyan("─" * 56))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Find openclaw.json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
OPENCLAW_JSON_PATHS = [
|
||||||
|
os.path.expanduser("~/.openclaw/openclaw.json"),
|
||||||
|
"/etc/openclaw/openclaw.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
def find_openclaw_json():
|
||||||
|
for p in OPENCLAW_JSON_PATHS:
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_config(path):
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def save_config(path, data):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MODELS = [
|
||||||
|
("claude-sonnet-4-6", "推荐:综合能力最强,速度均衡"),
|
||||||
|
("claude-opus-4-6", "能力最强,适合复杂任务,较慢"),
|
||||||
|
("claude-haiku-4-5", "速度最快,适合简单任务"),
|
||||||
|
]
|
||||||
|
KEEP_CURRENT = "(保持当前设置)"
|
||||||
|
KEEP_UNCHANGED = "(保持不变)"
|
||||||
|
|
||||||
|
def model_display_options(include_keep_current=False, include_keep_unchanged=False):
|
||||||
|
lines = []
|
||||||
|
for m, desc in MODELS:
|
||||||
|
lines.append(f"{m:<25} {dim(desc)}")
|
||||||
|
if include_keep_current:
|
||||||
|
lines.append(KEEP_CURRENT)
|
||||||
|
if include_keep_unchanged:
|
||||||
|
lines.append(KEEP_UNCHANGED)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def model_values(include_keep_current=False, include_keep_unchanged=False):
|
||||||
|
vals = [m for m, _ in MODELS]
|
||||||
|
if include_keep_current:
|
||||||
|
vals.append(None) # None = keep current
|
||||||
|
if include_keep_unchanged:
|
||||||
|
vals.append(None)
|
||||||
|
return vals
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 1: Default model selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def step1_default_model(config):
|
||||||
|
print_step(1, 4, "选择默认 Model")
|
||||||
|
print()
|
||||||
|
print("┌─────────────────────────────────────────────────────┐")
|
||||||
|
print("│ 选择 OpenClaw 默认 Model │")
|
||||||
|
print("│ │")
|
||||||
|
print("│ claude-sonnet-4-6 ← 推荐:综合能力最强,速度均衡 │")
|
||||||
|
print("│ claude-opus-4-6 能力最强,适合复杂任务,较慢 │")
|
||||||
|
print("│ claude-haiku-4-5 速度最快,适合简单任务 │")
|
||||||
|
print("│ (保持当前设置) │")
|
||||||
|
print("└─────────────────────────────────────────────────────┘")
|
||||||
|
print()
|
||||||
|
|
||||||
|
current = (config
|
||||||
|
.get("agents", {})
|
||||||
|
.get("defaults", {})
|
||||||
|
.get("model", {})
|
||||||
|
.get("primary", None))
|
||||||
|
|
||||||
|
if current:
|
||||||
|
print(f" {dim('当前默认 Model:')} {cyan(current)}")
|
||||||
|
else:
|
||||||
|
print(f" {dim('当前默认 Model:')} {yellow('(未设置)')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Find default selection index
|
||||||
|
default_idx = len(MODELS) # default: keep current
|
||||||
|
if current:
|
||||||
|
for i, (m, _) in enumerate(MODELS):
|
||||||
|
if m == current:
|
||||||
|
default_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
options = model_display_options(include_keep_current=True)
|
||||||
|
print(f" {bold('使用 ↑↓ 方向键选择,Enter 确认:')}")
|
||||||
|
print()
|
||||||
|
idx = arrow_select(None, options, default_index=default_idx)
|
||||||
|
|
||||||
|
if idx >= len(MODELS):
|
||||||
|
# Keep current
|
||||||
|
chosen_model = current # may be None
|
||||||
|
print(f"\n {green('✓')} 保持当前设置: {cyan(current) if current else yellow('(未设置)')}")
|
||||||
|
return chosen_model, False # (value, changed)
|
||||||
|
else:
|
||||||
|
chosen_model = MODELS[idx][0]
|
||||||
|
changed = (chosen_model != current)
|
||||||
|
print(f"\n {green('✓')} 选择: {cyan(chosen_model)}")
|
||||||
|
return chosen_model, changed
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 2: Agent model configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def step2_agent_config(config, default_model):
|
||||||
|
print_step(2, 4, "Agent Model 配置")
|
||||||
|
print()
|
||||||
|
|
||||||
|
agent_options = [
|
||||||
|
"不修改任何 Agent(默认)",
|
||||||
|
"全部按默认 Model 修改",
|
||||||
|
"逐一配置每个 Agent",
|
||||||
|
]
|
||||||
|
print(f" {bold('选择配置方式:')}")
|
||||||
|
print()
|
||||||
|
mode_idx = arrow_select(None, agent_options, default_index=0)
|
||||||
|
print()
|
||||||
|
|
||||||
|
agents = config.get("agents", {}).get("list", [])
|
||||||
|
agent_changes = {} # agent_id -> {"primary": ..., "fallback": ...}
|
||||||
|
|
||||||
|
if mode_idx == 0:
|
||||||
|
print(f" {green('✓')} 不修改任何 Agent")
|
||||||
|
return agent_changes
|
||||||
|
|
||||||
|
if mode_idx == 1:
|
||||||
|
# Apply default model to all agents
|
||||||
|
if not default_model:
|
||||||
|
print(f" {yellow('⚠️ 未设置默认 Model,跳过批量修改')}")
|
||||||
|
return agent_changes
|
||||||
|
for agent in agents:
|
||||||
|
aid = agent.get("id", "")
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
current_primary = (agent.get("model", {}) or {}).get("primary", None)
|
||||||
|
if current_primary != default_model:
|
||||||
|
agent_changes[aid] = {"primary": default_model, "fallback": None, "keep_fallback": True}
|
||||||
|
print(f" {green('✓')} 已将 {len(agent_changes)} 个 Agent 的 primary 设为 {cyan(default_model)}")
|
||||||
|
return agent_changes
|
||||||
|
|
||||||
|
# mode_idx == 2: configure one by one
|
||||||
|
if not agents:
|
||||||
|
print(f" {yellow('openclaw.json 中没有找到 Agent 列表')}")
|
||||||
|
return agent_changes
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
aid = agent.get("id", "")
|
||||||
|
aname = agent.get("name", aid)
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur_model_block = agent.get("model", {}) or {}
|
||||||
|
cur_primary = cur_model_block.get("primary", None)
|
||||||
|
cur_fallback = cur_model_block.get("fallback", None)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {bold(cyan('Agent:'))} {aname} ({dim(aid)})")
|
||||||
|
print(f" current primary: {cyan(cur_primary) if cur_primary else yellow('(未设置)')}")
|
||||||
|
print(f" current fallback: {cyan(cur_fallback) if cur_fallback else yellow('(未设置)')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Pick primary
|
||||||
|
print(f" {bold('选择新 primary Model (↑↓ Enter):')}")
|
||||||
|
print()
|
||||||
|
primary_options = model_display_options(include_keep_unchanged=True)
|
||||||
|
primary_vals = model_values(include_keep_unchanged=True)
|
||||||
|
# Default: keep unchanged
|
||||||
|
p_default = len(MODELS)
|
||||||
|
if cur_primary:
|
||||||
|
for i, (m, _) in enumerate(MODELS):
|
||||||
|
if m == cur_primary:
|
||||||
|
p_default = i
|
||||||
|
break
|
||||||
|
|
||||||
|
p_idx = arrow_select(None, primary_options, default_index=p_default)
|
||||||
|
new_primary = primary_vals[p_idx] if p_idx < len(MODELS) else cur_primary
|
||||||
|
print(f"\n {green('✓')} primary: {cyan(new_primary) if new_primary else yellow('(保持不变)')}")
|
||||||
|
|
||||||
|
# Pick fallback
|
||||||
|
print()
|
||||||
|
print(f" {bold('选择新 fallback Model (↑↓ Enter):')}")
|
||||||
|
print()
|
||||||
|
fallback_options = model_display_options() + ["(跳过 / 不设置 fallback)"]
|
||||||
|
f_default = len(MODELS) # skip
|
||||||
|
if cur_fallback:
|
||||||
|
for i, (m, _) in enumerate(MODELS):
|
||||||
|
if m == cur_fallback:
|
||||||
|
f_default = i
|
||||||
|
break
|
||||||
|
|
||||||
|
f_idx = arrow_select(None, fallback_options, default_index=f_default)
|
||||||
|
if f_idx < len(MODELS):
|
||||||
|
new_fallback = MODELS[f_idx][0]
|
||||||
|
else:
|
||||||
|
new_fallback = cur_fallback # keep existing
|
||||||
|
|
||||||
|
print(f"\n {green('✓')} fallback: {cyan(new_fallback) if new_fallback else yellow('(保持不变)')}")
|
||||||
|
|
||||||
|
# Record changes only if something actually differs
|
||||||
|
primary_changed = (new_primary is not None and new_primary != cur_primary)
|
||||||
|
fallback_changed = (new_fallback is not None and new_fallback != cur_fallback)
|
||||||
|
if primary_changed or fallback_changed:
|
||||||
|
agent_changes[aid] = {
|
||||||
|
"primary": new_primary if primary_changed else cur_primary,
|
||||||
|
"fallback": new_fallback if fallback_changed else cur_fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask whether to continue to next agent
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
cont = input(f" 还需要修改其他 Agent 吗?[Y/n]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print(f"\n\n{yellow('已取消,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
if cont in ("n", "no"):
|
||||||
|
break
|
||||||
|
|
||||||
|
return agent_changes
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 3: Summary + OCP check + confirm
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
OCP_MODELS_NEEDING_PROXY = ("claude-local", "claude-sonnet", "claude-opus", "claude-haiku")
|
||||||
|
|
||||||
|
def _needs_ocp(model_name):
|
||||||
|
if not model_name:
|
||||||
|
return False
|
||||||
|
return any(kw in model_name for kw in OCP_MODELS_NEEDING_PROXY)
|
||||||
|
|
||||||
|
def _ocp_running():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "--max-time", "3", "http://localhost:3456/health"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and result.stdout.strip() != ""
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def step3_summary_confirm(config, new_default_model, default_changed, agent_changes):
|
||||||
|
print_step(3, 4, "确认修改")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Build summary lines
|
||||||
|
current_default = (config
|
||||||
|
.get("agents", {})
|
||||||
|
.get("defaults", {})
|
||||||
|
.get("model", {})
|
||||||
|
.get("primary", None))
|
||||||
|
|
||||||
|
has_any_change = default_changed or bool(agent_changes)
|
||||||
|
|
||||||
|
if not has_any_change:
|
||||||
|
return False # caller will handle "no changes" message
|
||||||
|
|
||||||
|
print(f" {bold('即将应用以下修改:')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if default_changed:
|
||||||
|
old_str = cyan(current_default) if current_default else yellow("(未设置)")
|
||||||
|
new_str = cyan(new_default_model)
|
||||||
|
print(f" {bold('[defaults]')} {old_str} → {new_str}")
|
||||||
|
|
||||||
|
agents = config.get("agents", {}).get("list", [])
|
||||||
|
agent_map = {a.get("id", ""): a for a in agents if a.get("id")}
|
||||||
|
|
||||||
|
for aid, change in agent_changes.items():
|
||||||
|
agent = agent_map.get(aid, {})
|
||||||
|
cur_primary = (agent.get("model", {}) or {}).get("primary", None)
|
||||||
|
new_primary = change.get("primary", cur_primary)
|
||||||
|
old_str = cyan(cur_primary) if cur_primary else yellow("(未设置)")
|
||||||
|
new_str = cyan(new_primary) if new_primary else yellow("(未设置)")
|
||||||
|
aname = agent.get("name", aid)
|
||||||
|
if cur_primary == new_primary:
|
||||||
|
print(f" {bold(aname + ':')}{'':4}不变")
|
||||||
|
else:
|
||||||
|
print(f" {bold(aname + ':')}{'':4}{old_str} → {new_str}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# OCP check
|
||||||
|
all_new_models = []
|
||||||
|
if default_changed and new_default_model:
|
||||||
|
all_new_models.append(new_default_model)
|
||||||
|
for aid, change in agent_changes.items():
|
||||||
|
if change.get("primary"):
|
||||||
|
all_new_models.append(change["primary"])
|
||||||
|
if change.get("fallback"):
|
||||||
|
all_new_models.append(change["fallback"])
|
||||||
|
|
||||||
|
needs_ocp = any(_needs_ocp(m) for m in all_new_models)
|
||||||
|
if needs_ocp and not _ocp_running():
|
||||||
|
print(f" {yellow('⚠️ 检测到你选择的 Model 需要 openclaw-claude-proxy (ocp)')}")
|
||||||
|
print(f" {yellow(' 但 ocp 目前未运行。建议先安装并启动 ocp。')}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
ans = input(f" 继续 [c] / 退出查看 ocp 安装文档 [q]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print(f"\n\n{yellow('已取消,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
if ans in ("q", "quit", "exit"):
|
||||||
|
print()
|
||||||
|
print(f" {cyan('请参考 ocp 安装文档: https://github.com/openclaw/openclaw-claude-proxy')}")
|
||||||
|
print(f" {yellow('已退出,未做任何修改')}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
confirmed = yes_no(" 确认应用?", default_yes=True)
|
||||||
|
return confirmed
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Apply changes to openclaw.json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def apply_changes(config, new_default_model, default_changed, agent_changes):
|
||||||
|
if default_changed and new_default_model is not None:
|
||||||
|
agents_block = config.setdefault("agents", {})
|
||||||
|
defaults_block = agents_block.setdefault("defaults", {})
|
||||||
|
model_block = defaults_block.setdefault("model", {})
|
||||||
|
model_block["primary"] = new_default_model
|
||||||
|
|
||||||
|
if agent_changes:
|
||||||
|
agents_list = config.get("agents", {}).get("list", [])
|
||||||
|
for agent in agents_list:
|
||||||
|
aid = agent.get("id", "")
|
||||||
|
if aid in agent_changes:
|
||||||
|
change = agent_changes[aid]
|
||||||
|
if "model" not in agent or agent["model"] is None:
|
||||||
|
agent["model"] = {}
|
||||||
|
if change.get("primary") is not None:
|
||||||
|
agent["model"]["primary"] = change["primary"]
|
||||||
|
if change.get("fallback") is not None:
|
||||||
|
agent["model"]["fallback"] = change["fallback"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 4: Restart gateway
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def step4_restart_gateway():
|
||||||
|
print_step(4, 4, "重启 Gateway")
|
||||||
|
print()
|
||||||
|
restart = yes_no(" 配置已更新。是否立即重启 Gateway 以使配置生效?", default_yes=True)
|
||||||
|
if not restart:
|
||||||
|
print(f" {dim('跳过重启。请手动运行: openclaw gateway restart')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" {cyan('正在执行: openclaw gateway restart ...')}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["openclaw", "gateway", "restart"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" {green('✓ Gateway 重启成功')}")
|
||||||
|
if result.stdout.strip():
|
||||||
|
print(f" {dim(result.stdout.strip())}")
|
||||||
|
else:
|
||||||
|
print(f" {yellow('⚠️ Gateway 重启返回非零退出码')}")
|
||||||
|
if result.stderr.strip():
|
||||||
|
print(f" {red(result.stderr.strip())}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f" {yellow('⚠️ 未找到 openclaw 命令。请手动重启 Gateway。')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {red(f'重启失败: {e}')}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
print()
|
||||||
|
print(bold(cyan("╔══════════════════════════════════════════════════════╗")))
|
||||||
|
print(bold(cyan("║ OpenClaw Memory-Continuity 配置向导 ║")))
|
||||||
|
print(bold(cyan("╚══════════════════════════════════════════════════════╝")))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check if stdin is a tty — if not, skip interactive wizard
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
print(f" {yellow('非交互式终端,跳过配置向导')}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Find config file
|
||||||
|
config_path = find_openclaw_json()
|
||||||
|
if not config_path:
|
||||||
|
print(f" {red('错误: 未找到 openclaw.json')}")
|
||||||
|
print(f" {dim('已搜索以下路径:')}")
|
||||||
|
for p in OPENCLAW_JSON_PATHS:
|
||||||
|
print(f" {dim(p)}")
|
||||||
|
print(f" {yellow('请先初始化 OpenClaw 配置,再运行此向导')}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" {dim('配置文件:')} {cyan(config_path)}")
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
# ── Step 1 ──────────────────────────────────────────────────────────────
|
||||||
|
new_default_model, default_changed = step1_default_model(config)
|
||||||
|
|
||||||
|
# ── Step 2 ──────────────────────────────────────────────────────────────
|
||||||
|
agent_changes = step2_agent_config(config, new_default_model)
|
||||||
|
|
||||||
|
# ── Check for any changes ───────────────────────────────────────────────
|
||||||
|
has_any_change = default_changed or bool(agent_changes)
|
||||||
|
if not has_any_change:
|
||||||
|
print()
|
||||||
|
print(f" {green('无需修改,配置保持不变 ✅')}")
|
||||||
|
print()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# ── Step 3 ──────────────────────────────────────────────────────────────
|
||||||
|
confirmed = step3_summary_confirm(
|
||||||
|
config, new_default_model, default_changed, agent_changes
|
||||||
|
)
|
||||||
|
|
||||||
|
if not confirmed:
|
||||||
|
print()
|
||||||
|
print(f" {yellow('已取消,未做任何修改')}")
|
||||||
|
print()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Write changes
|
||||||
|
apply_changes(config, new_default_model, default_changed, agent_changes)
|
||||||
|
save_config(config_path, config)
|
||||||
|
print(f"\n {green('✓ 配置已写入')} {cyan(config_path)}")
|
||||||
|
|
||||||
|
# ── Step 4 ──────────────────────────────────────────────────────────────
|
||||||
|
step4_restart_gateway()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(bold(green("╔══════════════════════════════════════════════════════╗")))
|
||||||
|
print(bold(green("║ 配置向导完成 ✅ ║")))
|
||||||
|
print(bold(green("╚══════════════════════════════════════════════════════╝")))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify.sh — Verify memory-continuity installation (3-layer check)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/verify.sh [--workspace PATH] [--sample] [--all-agents]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --workspace PATH Override default workspace (~/.openclaw/workspace/main)
|
||||||
|
# --sample Show a sample high-importance CURRENT_STATE.md
|
||||||
|
# --all-agents Run 3-layer check across all detected agent workspaces
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
ok() { echo -e " ${GREEN}✅ $*${RESET}"; }
|
||||||
|
warn() { echo -e " ${YELLOW}⚠️ $*${RESET}"; }
|
||||||
|
fail() { echo -e " ${RED}❌ $*${RESET}"; }
|
||||||
|
info() { echo -e " ${CYAN}ℹ️ $*${RESET}"; }
|
||||||
|
header() { echo -e "\n${BOLD}$*${RESET}"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
WORKSPACE="${HOME}/.openclaw/workspace/main"
|
||||||
|
WORKSPACE_EXPLICIT=false
|
||||||
|
SHOW_SAMPLE=false
|
||||||
|
ALL_AGENTS=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--workspace)
|
||||||
|
WORKSPACE="$2"; WORKSPACE_EXPLICIT=true; shift 2 ;;
|
||||||
|
--sample)
|
||||||
|
SHOW_SAMPLE=true; shift ;;
|
||||||
|
--all-agents)
|
||||||
|
ALL_AGENTS=true; shift ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Expand ~ in workspace path
|
||||||
|
WORKSPACE="${WORKSPACE/#\~/$HOME}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --sample: print example state and exit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if $SHOW_SAMPLE; then
|
||||||
|
echo -e "\n${BOLD}Sample CURRENT_STATE.md (high-importance production task):${RESET}\n"
|
||||||
|
cat <<'EOF'
|
||||||
|
# Current State
|
||||||
|
> Last updated: 2026-03-19T11:42:00Z
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Complete production database migration v2→v3 for user activity table (sharded, ~80M rows).
|
||||||
|
|
||||||
|
## Current Step
|
||||||
|
Migration script written and reviewed. Dry-run on staging passed.
|
||||||
|
**Not yet tested against production replica.**
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
- Using online schema change (pt-online-schema-change) to avoid table lock
|
||||||
|
- Backfill batch size: 5,000 rows / 500ms to stay under replication lag threshold
|
||||||
|
- Rollback plan: swap back via feature flag, no destructive drop until T+48h
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
Schedule test run against prod replica — waiting for Tao to confirm migration
|
||||||
|
window (UTC+10, Thursday 23:00). Do NOT proceed without explicit sign-off.
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
- ⚠️ BLOCKED: Tao hasn't confirmed migration window (UTC+10 Thu 23:00)
|
||||||
|
- ⚠️ Prod replica test not yet scheduled
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
- Skipping production replica test → potential data loss on edge cases not covered by staging schema
|
||||||
|
- Migration window must be off-peak; violating this risks exceeding replication lag SLA (>30s triggers alert)
|
||||||
|
|
||||||
|
## Unsurfaced Results
|
||||||
|
- Staging dry-run log: /tmp/pt-osc-staging-2026-03-19.log (not yet reviewed for warnings)
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --all-agents: detect all agent workspaces and run checks for each
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
OPENCLAW_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
|
||||||
|
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
|
||||||
|
PLUGIN_DIR_SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
detect_all_agent_workspaces() {
|
||||||
|
if [[ ! -f "$CONFIG_FILE" ]] || ! command -v python3 &>/dev/null; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
|
||||||
|
import json, sys, os
|
||||||
|
config_file = sys.argv[1]
|
||||||
|
openclaw_dir = sys.argv[2]
|
||||||
|
with open(config_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', os.path.join(openclaw_dir, 'workspace', 'main'))
|
||||||
|
seen = set()
|
||||||
|
for agent in data.get('agents', {}).get('list', []):
|
||||||
|
agent_id = agent.get('id', '')
|
||||||
|
if not agent_id or agent_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(agent_id)
|
||||||
|
name = agent.get('name', agent_id)
|
||||||
|
workspace = agent.get('workspace', default_ws if agent_id == 'main' else os.path.join(openclaw_dir, 'workspaces', agent_id))
|
||||||
|
workspace = os.path.expanduser(workspace)
|
||||||
|
print('{}|{}|{}'.format(agent_id, name, workspace))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if $ALL_AGENTS; then
|
||||||
|
GRAY='\033[0;90m'
|
||||||
|
|
||||||
|
echo -e "\n${BOLD}memory-continuity — Multi-Agent Verifier${RESET}"
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
|
||||||
|
AGENT_LINES=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
AGENT_LINES+=("$line")
|
||||||
|
done < <(detect_all_agent_workspaces 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ ${#AGENT_LINES[@]} -eq 0 ]]; then
|
||||||
|
echo -e "${RED}No agents detected. Is openclaw.json present and python3 available?${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Filter to alive agents (workspace directory exists)
|
||||||
|
ALIVE_LINES=()
|
||||||
|
SKIPPED=0
|
||||||
|
for line in "${AGENT_LINES[@]}"; do
|
||||||
|
IFS='|' read -r agent_id agent_name agent_ws <<< "$line"
|
||||||
|
if [[ -d "$agent_ws" ]]; then
|
||||||
|
ALIVE_LINES+=("$line")
|
||||||
|
else
|
||||||
|
echo -e " ${GRAY}Skipping ${agent_name} (${agent_id}) — workspace not found: ${agent_ws}${RESET}"
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $SKIPPED -gt 0 ]]; then
|
||||||
|
echo -e " ${GRAY}Skipped ${SKIPPED} agent(s) with missing workspace directories${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#ALIVE_LINES[@]} -eq 0 ]]; then
|
||||||
|
echo -e "${YELLOW}All ${#AGENT_LINES[@]} agent(s) in config have missing workspace directories.${RESET}"
|
||||||
|
echo -e "${YELLOW}You may need to initialize them first.${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL_PASS=0
|
||||||
|
TOTAL_FAIL=0
|
||||||
|
|
||||||
|
for line in "${ALIVE_LINES[@]}"; do
|
||||||
|
IFS='|' read -r agent_id agent_name agent_ws <<< "$line"
|
||||||
|
echo -e "\n${BOLD}Agent: ${agent_name} (${agent_id})${RESET}"
|
||||||
|
echo -e " Workspace: ${agent_ws}"
|
||||||
|
|
||||||
|
AGENT_ERRORS=0
|
||||||
|
AGENT_WARNINGS=0
|
||||||
|
|
||||||
|
# Layer 1: SKILL.md in workspace
|
||||||
|
SKILL_PATH="${agent_ws}/skills/memory-continuity/SKILL.md"
|
||||||
|
if [[ -f "$SKILL_PATH" ]]; then
|
||||||
|
ok "SKILL.md installed"
|
||||||
|
else
|
||||||
|
fail "SKILL.md missing at ${SKILL_PATH}"
|
||||||
|
AGENT_ERRORS=$((AGENT_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Layer 2: continuity_doctor.py
|
||||||
|
DOCTOR="${PLUGIN_DIR_SELF}/scripts/continuity_doctor.py"
|
||||||
|
if command -v python3 &>/dev/null && [[ -f "$DOCTOR" ]]; then
|
||||||
|
if python3 "$DOCTOR" --workspace "$agent_ws" &>/dev/null; then
|
||||||
|
ok "continuity_doctor.py passed"
|
||||||
|
else
|
||||||
|
warn "continuity_doctor.py exited non-zero"
|
||||||
|
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "python3 or continuity_doctor.py unavailable — skipped"
|
||||||
|
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Layer 3: CURRENT_STATE.md
|
||||||
|
STATE_FILE="${agent_ws}/memory/CURRENT_STATE.md"
|
||||||
|
if [[ ! -f "$STATE_FILE" ]]; then
|
||||||
|
warn "CURRENT_STATE.md not found (normal for new installs)"
|
||||||
|
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
|
||||||
|
else
|
||||||
|
LINE_COUNT=$(grep -c '[^[:space:]]' "$STATE_FILE" || true)
|
||||||
|
if [[ $LINE_COUNT -gt 2 ]]; then
|
||||||
|
ok "CURRENT_STATE.md present (${LINE_COUNT} non-blank lines)"
|
||||||
|
else
|
||||||
|
warn "CURRENT_STATE.md appears empty or placeholder"
|
||||||
|
AGENT_WARNINGS=$((AGENT_WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $AGENT_ERRORS -eq 0 ]]; then
|
||||||
|
echo -e " ${GREEN}→ PASS${RESET} (${AGENT_WARNINGS} warning(s))"
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${RED}→ FAIL${RESET} (${AGENT_ERRORS} error(s), ${AGENT_WARNINGS} warning(s))"
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
SUMMARY_DETAIL=""
|
||||||
|
[[ $SKIPPED -gt 0 ]] && SUMMARY_DETAIL=", ${SKIPPED} skipped (no workspace)"
|
||||||
|
echo -e "${BOLD}Summary: ${TOTAL_PASS} passed, ${TOTAL_FAIL} failed (of ${#ALIVE_LINES[@]} alive agents${SUMMARY_DETAIL})${RESET}"
|
||||||
|
[[ $TOTAL_FAIL -gt 0 ]] && exit 1 || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolve WORKSPACE from openclaw.json if not explicitly set via --workspace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# If not explicitly set, try to derive from the first alive agent in openclaw.json
|
||||||
|
if ! $WORKSPACE_EXPLICIT && [[ -f "$CONFIG_FILE" ]] && command -v python3 &>/dev/null; then
|
||||||
|
RESOLVED_WS=$(python3 - "$CONFIG_FILE" "$OPENCLAW_DIR" <<'PYEOF'
|
||||||
|
import json, sys, os
|
||||||
|
config_file = sys.argv[1]
|
||||||
|
openclaw_dir = sys.argv[2]
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
default_ws = data.get('agents', {}).get('defaults', {}).get('workspace', '')
|
||||||
|
for agent in data.get('agents', {}).get('list', []):
|
||||||
|
ws = agent.get('workspace', default_ws)
|
||||||
|
if ws:
|
||||||
|
print(os.path.expanduser(ws))
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
if [[ -n "$RESOLVED_WS" ]]; then
|
||||||
|
WORKSPACE="$RESOLVED_WS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Header
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -e "\n${BOLD}memory-continuity — Installation Verifier${RESET}"
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
|
||||||
|
ERRORS=0
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Layer 1: Required files exist
|
||||||
|
# ===========================================================================
|
||||||
|
header "Layer 1: Required files"
|
||||||
|
|
||||||
|
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
check_file() {
|
||||||
|
local rel="$1"
|
||||||
|
local path="${PLUGIN_DIR}/${rel}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
ok "$rel"
|
||||||
|
else
|
||||||
|
fail "$rel — NOT FOUND (expected at ${path})"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_file "SKILL.md"
|
||||||
|
check_file "scripts/continuity_doctor.py"
|
||||||
|
check_file "openclaw.plugin.json"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Layer 2: continuity_doctor.py runs without error
|
||||||
|
# ===========================================================================
|
||||||
|
header "Layer 2: Tool availability"
|
||||||
|
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
fail "python3 not found in PATH"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
ok "python3 found ($(python3 --version 2>&1))"
|
||||||
|
|
||||||
|
DOCTOR="${PLUGIN_DIR}/scripts/continuity_doctor.py"
|
||||||
|
if [[ ! -f "$DOCTOR" ]]; then
|
||||||
|
fail "continuity_doctor.py missing — skipping doctor check"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
info "Running: python3 scripts/continuity_doctor.py --workspace ${WORKSPACE}"
|
||||||
|
echo ""
|
||||||
|
if python3 "$DOCTOR" --workspace "$WORKSPACE" 2>&1 | sed 's/^/ /'; then
|
||||||
|
echo ""
|
||||||
|
ok "continuity_doctor.py exited cleanly"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
warn "continuity_doctor.py exited with non-zero status (see output above)"
|
||||||
|
WARNINGS=$((WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Layer 3: CURRENT_STATE.md content check
|
||||||
|
# ===========================================================================
|
||||||
|
header "Layer 3: Workspace state"
|
||||||
|
|
||||||
|
STATE_FILE="${WORKSPACE}/memory/CURRENT_STATE.md"
|
||||||
|
|
||||||
|
if [[ ! -f "$STATE_FILE" ]]; then
|
||||||
|
warn "CURRENT_STATE.md not found at: ${STATE_FILE}"
|
||||||
|
warn "This is normal for a brand-new install — state will be created after your first session."
|
||||||
|
WARNINGS=$((WARNINGS + 1))
|
||||||
|
else
|
||||||
|
# Detect placeholder / trivial content:
|
||||||
|
# - File is empty
|
||||||
|
# - Only contains the header line "# Current State"
|
||||||
|
# - Contains the phrase "No active" or "placeholder"
|
||||||
|
CONTENT=$(cat "$STATE_FILE")
|
||||||
|
LINE_COUNT=$(echo "$CONTENT" | grep -c '[^[:space:]]' || true)
|
||||||
|
|
||||||
|
IS_PLACEHOLDER=false
|
||||||
|
|
||||||
|
if [[ $LINE_COUNT -le 2 ]]; then
|
||||||
|
IS_PLACEHOLDER=true
|
||||||
|
elif ! echo "$CONTENT" | grep -qE '##\s+(Objective|Current Step|In Flight|Next Action|Blocked)'; then
|
||||||
|
# Has some lines but none of the expected structured sections
|
||||||
|
IS_PLACEHOLDER=true
|
||||||
|
else
|
||||||
|
# Check if the Objective section itself is trivial (placeholder text right after the heading)
|
||||||
|
OBJECTIVE_VALUE=$(echo "$CONTENT" | awk '/^## Objective/{found=1; next} found && /^##/{exit} found && /[^[:space:]]/{print; exit}')
|
||||||
|
if echo "$OBJECTIVE_VALUE" | grep -qiE '^\s*(no active|placeholder|todo|tbd|empty|n\/a|none|untitled)$'; then
|
||||||
|
IS_PLACEHOLDER=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $IS_PLACEHOLDER; then
|
||||||
|
echo ""
|
||||||
|
warn "CURRENT_STATE.md exists but appears to be empty or a placeholder."
|
||||||
|
warn "The plugin is installed correctly, but your working state hasn't been"
|
||||||
|
warn "captured yet. After your first real work session it will be populated"
|
||||||
|
warn "automatically."
|
||||||
|
echo ""
|
||||||
|
warn "To see what a high-importance state entry looks like, run:"
|
||||||
|
echo -e " ${CYAN}bash scripts/verify.sh --sample${RESET}"
|
||||||
|
WARNINGS=$((WARNINGS + 1))
|
||||||
|
else
|
||||||
|
ok "CURRENT_STATE.md found with substantive content (${LINE_COUNT} non-blank lines)"
|
||||||
|
# Show last-updated line if present
|
||||||
|
UPDATED=$(grep -m1 'Last updated' "$STATE_FILE" || true)
|
||||||
|
[[ -n "$UPDATED" ]] && info "$UPDATED"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Summary
|
||||||
|
# ===========================================================================
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||||||
|
echo -e "${GREEN}${BOLD}All checks passed. memory-continuity is correctly installed.${RESET}"
|
||||||
|
elif [[ $ERRORS -eq 0 ]]; then
|
||||||
|
echo -e "${YELLOW}${BOLD}Checks passed with ${WARNINGS} warning(s). Review warnings above.${RESET}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}${BOLD}${ERRORS} error(s), ${WARNINGS} warning(s). Installation may be incomplete.${RESET}"
|
||||||
|
echo -e "${RED}Re-run the installer: bash scripts/post-install.sh${RESET}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user