mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-21 21:15:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0fe6b40b | ||
|
|
2184442bc2 | ||
|
|
719723225d | ||
|
|
9f43452f03 | ||
|
|
fe13ea19be | ||
|
|
772c964740 | ||
|
|
b12bd15c8b | ||
|
|
c366d3ec47 | ||
|
|
449f1f4916 | ||
|
|
6a60c1806d | ||
|
|
9e25ec5e95 | ||
|
|
312d892853 | ||
|
|
33c3558625 | ||
|
|
201c52bf75 | ||
|
|
d61c242e9f |
@@ -1,6 +1,6 @@
|
||||
# memory-continuity
|
||||
|
||||
**Current release:** `v2.1.0`
|
||||
**Current release:** `v2.7.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.
|
||||
|
||||
@@ -69,6 +69,18 @@ No npm install, no API keys, no external database.
|
||||
### Verify
|
||||
|
||||
```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
|
||||
# Should show: [memory-continuity] Plugin registered successfully
|
||||
```
|
||||
@@ -110,6 +122,7 @@ The plugin works with zero configuration. Optional settings in `openclaw.json`:
|
||||
| `maxStateLines` | `50` | Max lines for CURRENT_STATE.md |
|
||||
| `archiveOnNew` | `true` | Archive state to `session_archive/` before `/new` |
|
||||
| `autoExtract` | `true` | Auto-extract state from conversation at session end |
|
||||
| `maxArchiveCount` | `20` | Maximum archive files to keep (oldest auto-deleted) |
|
||||
|
||||
## The checkpoint file
|
||||
|
||||
@@ -157,6 +170,47 @@ scp $WORKSPACE/memory/CURRENT_STATE.md newhost:$WORKSPACE/memory/
|
||||
|
||||
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
|
||||
|
||||
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 +243,7 @@ memory-continuity/
|
||||
│ └── phase2-hook-validation.md
|
||||
└── scripts/
|
||||
├── post-install.sh # Automated installer
|
||||
├── verify.sh # 3-layer install verifier
|
||||
└── continuity_doctor.py # Health check
|
||||
```
|
||||
|
||||
|
||||
@@ -130,6 +130,22 @@ Update the file by **overwriting** it, not appending, at these moments:
|
||||
| Before handoff / subagent exit | Preserves outputs and unsurfaced results |
|
||||
| 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
|
||||
|
||||
`CURRENT_STATE.md` should usually stay under about 40 lines and be readable in
|
||||
|
||||
@@ -5,6 +5,9 @@ import path from "node:path";
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_ARCHIVE_COUNT = 20;
|
||||
const MAX_MEMORY_FILES = 500;
|
||||
|
||||
const PLACEHOLDER_VALUES = new Set([
|
||||
"", "none", "n/a", "na", "idle",
|
||||
"[one sentence: what are we trying to accomplish]",
|
||||
@@ -82,13 +85,58 @@ function buildSnapshot(md) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function archiveState(workspaceDir, md) {
|
||||
function archiveState(workspaceDir, md, config = {}) {
|
||||
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 archiveDir = path.join(workspaceDir, "memory", "session_archive");
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), md, "utf8");
|
||||
|
||||
// Enforce archive count limit
|
||||
const maxCount = config.maxArchiveCount || MAX_ARCHIVE_COUNT;
|
||||
const files = fs.readdirSync(archiveDir).sort();
|
||||
if (files.length > maxCount) {
|
||||
const toDelete = files.slice(0, files.length - maxCount);
|
||||
for (const f of toDelete) {
|
||||
try { fs.unlinkSync(path.join(archiveDir, f)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up memory directory
|
||||
cleanupMemoryDir(workspaceDir);
|
||||
}
|
||||
|
||||
function cleanupMemoryDir(workspaceDir) {
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
try {
|
||||
const entries = fs.readdirSync(memoryDir);
|
||||
|
||||
// Clean up legacy STATE_ARCHIVE_*.md files from memory/ root
|
||||
const legacyArchives = entries.filter(f => /^STATE_ARCHIVE_.*\.md$/.test(f));
|
||||
for (const f of legacyArchives) {
|
||||
try { fs.unlinkSync(path.join(memoryDir, f)); } catch {}
|
||||
}
|
||||
|
||||
// Check total file count (top level only)
|
||||
const remaining = fs.readdirSync(memoryDir);
|
||||
const fileEntries = remaining.filter(f => {
|
||||
try { return fs.statSync(path.join(memoryDir, f)).isFile(); } catch { return false; }
|
||||
});
|
||||
|
||||
if (fileEntries.length > MAX_MEMORY_FILES) {
|
||||
const PROTECTED = new Set(["CURRENT_STATE.md", "MEMORY.md", "INDEX.md"]);
|
||||
const deletable = fileEntries
|
||||
.filter(f => !PROTECTED.has(f) && f.endsWith(".md"))
|
||||
.map(f => ({ name: f, mtime: fs.statSync(path.join(memoryDir, f)).mtimeMs }))
|
||||
.sort((a, b) => a.mtime - b.mtime);
|
||||
|
||||
const toRemove = deletable.slice(0, fileEntries.length - 450);
|
||||
for (const { name } of toRemove) {
|
||||
try { fs.unlinkSync(path.join(memoryDir, name)); } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function extractStateFromMessages(messages) {
|
||||
@@ -222,7 +270,7 @@ const plugin = {
|
||||
if (!md) return;
|
||||
|
||||
if (buildSnapshot(md)) {
|
||||
archiveState(ws, md);
|
||||
archiveState(ws, md, config);
|
||||
log.info?.("[memory-continuity] Archived state before /new");
|
||||
}
|
||||
}, { priority: 10 });
|
||||
@@ -258,14 +306,7 @@ const plugin = {
|
||||
return cleaned.length > 10;
|
||||
});
|
||||
|
||||
// Don't overwrite meaningful state with trivial conversations
|
||||
// (e.g., "/new" then "what was my secret?" — only 1 real message)
|
||||
const existing = readFile(statePath);
|
||||
if (existing && buildSnapshot(existing) && realUserMsgs.length < 2) {
|
||||
log.info?.("[memory-continuity] Conversation too short to overwrite existing state (" + realUserMsgs.length + " real msgs)");
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = extractStateFromMessages(messages);
|
||||
if (!newState) {
|
||||
log.info?.("[memory-continuity] No extractable state from conversation");
|
||||
@@ -274,8 +315,7 @@ const plugin = {
|
||||
|
||||
// Archive previous state if it exists
|
||||
if (existing) {
|
||||
const archivePath = statePath.replace(/CURRENT_STATE\.md$/, `STATE_ARCHIVE_${Date.now()}.md`);
|
||||
writeFile(archivePath, existing);
|
||||
archiveState(ws, existing, config);
|
||||
}
|
||||
|
||||
writeFile(statePath, newState);
|
||||
|
||||
+23
-5
@@ -2,7 +2,7 @@
|
||||
"id": "memory-continuity",
|
||||
"name": "Memory Continuity",
|
||||
"description": "Preserves working state across /new, reset, compaction, and gateway restarts via a simple markdown checkpoint file.",
|
||||
"version": "2.3.0",
|
||||
"version": "2.7.0",
|
||||
"source": "https://github.com/dtzp555-max/memory-continuity",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
@@ -22,12 +22,30 @@
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"maxStateLines": { "label": "Max state file lines", "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" }
|
||||
"maxStateLines": {
|
||||
"label": "Max state file lines",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "memory-continuity",
|
||||
"version": "2.3.0",
|
||||
"version": "2.7.0",
|
||||
"description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -15,6 +15,7 @@ import sys
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -32,7 +33,7 @@ class Severity:
|
||||
# ---------------------------------------------------------------------------
|
||||
class DiagnosticReport:
|
||||
def __init__(self):
|
||||
self.entries: list[tuple[str, str]] = []
|
||||
self.entries: List[Tuple[str, str]] = []
|
||||
self._worst = Severity.OK
|
||||
|
||||
def add(self, severity: str, message: str):
|
||||
@@ -78,7 +79,7 @@ PLACEHOLDER_PATTERNS = [
|
||||
# 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."""
|
||||
state_file = workspace / "memory" / "CURRENT_STATE.md"
|
||||
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:
|
||||
"""Check all required sections are present and not placeholder-only."""
|
||||
content = state_file.read_text(encoding="utf-8")
|
||||
sections_found: dict[str, str] = {}
|
||||
sections_found: Dict[str, str] = {}
|
||||
|
||||
for section in REQUIRED_SECTIONS:
|
||||
# Match ## Section or ## Section\n
|
||||
|
||||
+192
-14
@@ -4,7 +4,7 @@
|
||||
# This script:
|
||||
# 1. Copies the plugin to ~/.openclaw/extensions/memory-continuity/
|
||||
# 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:
|
||||
# bash scripts/post-install.sh
|
||||
@@ -50,16 +50,13 @@ if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo " WARNING: $CONFIG_FILE not found. Skipping config update."
|
||||
echo " You'll need to manually add the plugin entry."
|
||||
else
|
||||
# Check if memory-continuity entry already exists
|
||||
# Always ensure allow list, install record, and entry are present (idempotent)
|
||||
python3 -c "
|
||||
python3 -c "
|
||||
import json
|
||||
path = '$CONFIG_FILE'
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if 'plugins' not in data:
|
||||
data['plugins'] = {}
|
||||
# Add to plugins.allow so OpenClaw trusts this plugin (no provenance warning)
|
||||
allow_list = data['plugins'].get('allow', [])
|
||||
if 'memory-continuity' not in allow_list:
|
||||
allow_list.append('memory-continuity')
|
||||
@@ -77,7 +74,6 @@ data['plugins']['entries']['memory-continuity'] = {
|
||||
'autoExtract': True
|
||||
}
|
||||
}
|
||||
# Add install record for provenance tracking (eliminates untracked-code warning)
|
||||
if 'installs' not in data['plugins']:
|
||||
data['plugins']['installs'] = {}
|
||||
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."
|
||||
fi
|
||||
|
||||
# Step 3: Restart gateway
|
||||
echo "[3/3] Restarting gateway ..."
|
||||
if command -v openclaw &>/dev/null; then
|
||||
if openclaw gateway status 2>&1 | grep -q "running"; then
|
||||
openclaw gateway restart 2>/dev/null && echo " Gateway restarted" || echo " Gateway restart failed — try: openclaw gateway restart"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Detect agents and install SKILL.md to selected workspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[3/3] Detecting OpenClaw agents ..."
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "🧙 启动配置向导..."
|
||||
python3 "$(dirname "$0")/setup_wizard.py" || true
|
||||
|
||||
echo ""
|
||||
echo "=== Installation complete ==="
|
||||
echo ""
|
||||
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 "Test with:"
|
||||
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