mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-19 09:42:42 +00:00
release: v5.0.0 — conservative subagent support (parent seed + child recovery)
- Add workspace resolution helpers (isSubagentSession, parseParentAgentId, resolveAgentWorkspace) - Parent→Child seeding: inject parent CURRENT_STATE.md into subagent context at startup - Child→Parent recovery: new subagent_ended hook reads child unsurfaced results, merges into parent - Add subagentSeed and subagentRecovery config keys (both default: true) - Add /mc subagents command for cross-workspace state overview - Bump lifecycle plugin to v5.0.0, mc-plugin to v2.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained 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,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"`.
|
||||||
@@ -708,6 +708,71 @@ ${truncate(lastAssistant, 500)}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the current session is a subagent by checking the session key format.
|
||||||
|
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
|
||||||
|
*/
|
||||||
|
function isSubagentSession(ctx) {
|
||||||
|
const key = ctx?.sessionKey || ctx?.SessionKey || "";
|
||||||
|
return /^agent:[^:]+:subagent:/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the parent agent ID from a subagent session key.
|
||||||
|
* Session key format: agent:<parentAgentId>:subagent:<uuid>
|
||||||
|
* Returns null if not a subagent key.
|
||||||
|
*/
|
||||||
|
function parseParentAgentId(sessionKey) {
|
||||||
|
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
|
||||||
|
return match?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve workspace directory for a given agent ID.
|
||||||
|
* Searches the OpenClaw config for agent workspace mappings.
|
||||||
|
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
|
||||||
|
* or ~/.openclaw/workspaces/<agentId> for named agents.
|
||||||
|
*/
|
||||||
|
function resolveAgentWorkspace(agentId) {
|
||||||
|
if (!agentId) return null;
|
||||||
|
|
||||||
|
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
|
||||||
|
|
||||||
|
// Try to read config for explicit workspace mapping
|
||||||
|
try {
|
||||||
|
const configPath = path.join(base, "openclaw.json");
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
const agents = config?.agents?.list || [];
|
||||||
|
const entry = agents.find(a => a.id === agentId || a.name === agentId);
|
||||||
|
if (entry?.workspace) return entry.workspace;
|
||||||
|
|
||||||
|
// Check defaults
|
||||||
|
if (agentId === "main") {
|
||||||
|
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fallback heuristics
|
||||||
|
if (agentId === "main") return path.join(base, "workspace", "main");
|
||||||
|
|
||||||
|
// Named agents typically use ~/.openclaw/workspaces/<agentId>
|
||||||
|
const namedWs = path.join(base, "workspaces", agentId);
|
||||||
|
if (fs.existsSync(namedWs)) return namedWs;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Unsurfaced Results section from a state markdown file.
|
||||||
|
* Returns the raw text content of the section, or null if empty/placeholder.
|
||||||
|
*/
|
||||||
|
function extractUnsurfacedResults(md) {
|
||||||
|
if (!md) return null;
|
||||||
|
const section = extractSection(md, "Unsurfaced Results");
|
||||||
|
if (!section || !isMeaningful(section)) return null;
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin Definition
|
// Plugin Definition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -750,6 +815,36 @@ const plugin = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parent seed: if this is a subagent, inject parent's working state
|
||||||
|
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
|
||||||
|
try {
|
||||||
|
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
|
||||||
|
const parentAgentId = parseParentAgentId(sessionKey);
|
||||||
|
if (parentAgentId) {
|
||||||
|
const parentWs = resolveAgentWorkspace(parentAgentId);
|
||||||
|
if (parentWs) {
|
||||||
|
const parentStatePath = resolveStatePath(parentWs);
|
||||||
|
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
|
||||||
|
if (parentMd) {
|
||||||
|
const parentSnapshot = buildSnapshot(parentMd);
|
||||||
|
if (parentSnapshot) {
|
||||||
|
parts.push(
|
||||||
|
"=== PARENT AGENT CONTEXT ===\n" +
|
||||||
|
"The following is the parent agent's working state. " +
|
||||||
|
"Use this to understand the broader task context.\n" +
|
||||||
|
parentSnapshot +
|
||||||
|
"\n=== END PARENT CONTEXT ==="
|
||||||
|
);
|
||||||
|
log.info?.("[memory-continuity] Injected parent state seed for subagent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prependSystemContext:
|
prependSystemContext:
|
||||||
parts.join("\n\n") + "\n\n" +
|
parts.join("\n\n") + "\n\n" +
|
||||||
@@ -896,6 +991,116 @@ const plugin = {
|
|||||||
}
|
}
|
||||||
}, { priority: 90 });
|
}, { priority: 90 });
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// HOOK 6: subagent_ended — recover child's unsurfaced results
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
api.on("subagent_ended", async (event, _ctx) => {
|
||||||
|
const config = getConfig();
|
||||||
|
if (config.subagentRecovery === false) return;
|
||||||
|
|
||||||
|
const parentWs = _ctx?.workspaceDir;
|
||||||
|
if (!parentWs) return;
|
||||||
|
|
||||||
|
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
|
||||||
|
if (!childSessionKey) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process successful completions (not kills/errors)
|
||||||
|
const outcome = event?.outcome || "";
|
||||||
|
const reason = event?.reason || "";
|
||||||
|
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse child agent ID from session key
|
||||||
|
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
|
||||||
|
const childAgentId = childMatch?.[1];
|
||||||
|
if (!childAgentId) return;
|
||||||
|
|
||||||
|
const childWs = resolveAgentWorkspace(childAgentId);
|
||||||
|
if (!childWs) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read child's state
|
||||||
|
const childStatePath = resolveStatePath(childWs);
|
||||||
|
const childMd = childStatePath ? readFile(childStatePath) : null;
|
||||||
|
if (!childMd) return;
|
||||||
|
|
||||||
|
// Extract unsurfaced results from child
|
||||||
|
const childUnsurfaced = extractUnsurfacedResults(childMd);
|
||||||
|
if (!childUnsurfaced) {
|
||||||
|
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also extract child's objective for context
|
||||||
|
const childObjective = extractSection(childMd, "Objective");
|
||||||
|
|
||||||
|
// Read parent's current state
|
||||||
|
const parentStatePath = resolveStatePath(parentWs);
|
||||||
|
if (!parentStatePath) return;
|
||||||
|
|
||||||
|
let parentMd = readFile(parentStatePath);
|
||||||
|
if (!parentMd) {
|
||||||
|
parentMd = 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) {
|
||||||
|
const lines = mergedUnsurfaced.split("\n");
|
||||||
|
let kept = [];
|
||||||
|
let tokens = 0;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const lineTokens = estimateTokens(lines[i]);
|
||||||
|
if (tokens + lineTokens > 500) break;
|
||||||
|
kept.unshift(lines[i]);
|
||||||
|
tokens += lineTokens;
|
||||||
|
}
|
||||||
|
const truncated = kept.join("\n");
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
||||||
|
"## Unsurfaced Results\n" + truncated
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
||||||
|
"## Unsurfaced Results\n" + mergedUnsurfaced
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the timestamp
|
||||||
|
parentMd = parentMd.replace(
|
||||||
|
/^> Last updated:.*$/m,
|
||||||
|
`> Last updated: ${now}`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFile(parentStatePath, parentMd);
|
||||||
|
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
|
||||||
|
}
|
||||||
|
}, { priority: 50 });
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// SERVICE: mc:recall — programmatic interface for other plugins
|
// SERVICE: mc:recall — programmatic interface for other plugins
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
+37
-1
@@ -780,6 +780,40 @@ function cmdRecall(args) {
|
|||||||
return out.trimEnd();
|
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() {
|
function cmdHelp() {
|
||||||
return `MC Commands (Memory Continuity)
|
return `MC Commands (Memory Continuity)
|
||||||
─────────────────────────────
|
─────────────────────────────
|
||||||
@@ -796,7 +830,8 @@ function cmdHelp() {
|
|||||||
/mc settings <k> <v> Update a setting
|
/mc settings <k> <v> Update a setting
|
||||||
/mc compact [agent] Compress state file
|
/mc compact [agent] Compress state file
|
||||||
/mc export [agent|all] Export (--from/--to/--tag)
|
/mc export [agent|all] Export (--from/--to/--tag)
|
||||||
/mc recall <topic> Find relevant history by topic`;
|
/mc recall <topic> Find relevant history by topic
|
||||||
|
/mc subagents Subagent state overview across workspaces`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plugin entry point ──────────────────────────────────────────────────
|
// ── Plugin entry point ──────────────────────────────────────────────────
|
||||||
@@ -831,6 +866,7 @@ export default function (api) {
|
|||||||
case "tags": text = cmdTags(subargs || null); break;
|
case "tags": text = cmdTags(subargs || null); break;
|
||||||
case "summary": text = cmdSummary(subargs || null); break;
|
case "summary": text = cmdSummary(subargs || null); break;
|
||||||
case "recall": text = cmdRecall(subargs); break;
|
case "recall": text = cmdRecall(subargs); break;
|
||||||
|
case "subagents": text = cmdSubagents(subargs || null); break;
|
||||||
case "help": case "--help": case "-h": case "":
|
case "help": case "--help": case "-h": case "":
|
||||||
text = cmdHelp(); break;
|
text = cmdHelp(); break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "mc",
|
"id": "mc",
|
||||||
"name": "Memory Continuity Commands",
|
"name": "Memory Continuity Commands",
|
||||||
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc settings, etc.",
|
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc settings, etc.",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
|
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"category": "commands",
|
"category": "commands",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mc-plugin",
|
"name": "mc-plugin",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
|
"description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+19
-1
@@ -2,7 +2,7 @@
|
|||||||
"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": "4.0.0",
|
"version": "5.0.0",
|
||||||
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
|
"author": { "name": "dtzp555-max", "url": "https://github.com/dtzp555-max" },
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "https://github.com/dtzp555-max/memory-continuity",
|
"repository": "https://github.com/dtzp555-max/memory-continuity",
|
||||||
@@ -80,6 +80,16 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 30,
|
"default": 30,
|
||||||
"description": "Days before archives move to cold storage (0 = disabled)"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -127,6 +137,14 @@
|
|||||||
"archiveDecayDays": {
|
"archiveDecayDays": {
|
||||||
"label": "Archive decay days",
|
"label": "Archive decay days",
|
||||||
"help": "Move archives older than N days to cold/ subfolder (0 to disable)"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "memory-continuity",
|
"name": "memory-continuity",
|
||||||
"version": "4.0.0",
|
"version": "5.0.0",
|
||||||
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
|
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Reference in New Issue
Block a user