|
|
@@ -1,6 +1,53 @@
|
|
|
import fs from "fs";
|
|
|
import os from "os";
|
|
|
import path from "path";
|
|
|
+import { fileURLToPath } from "url";
|
|
|
+
|
|
|
+const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
+const COMMAND_LIST_PATH = path.join(MODULE_DIR, "command-list.json");
|
|
|
+const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
|
+const DEFAULT_COMMANDS = [
|
|
|
+ "start",
|
|
|
+ "stop",
|
|
|
+ "restart",
|
|
|
+ "status",
|
|
|
+ "logs",
|
|
|
+ "log",
|
|
|
+ "model",
|
|
|
+ "models",
|
|
|
+ "agent",
|
|
|
+ "agents",
|
|
|
+ "session",
|
|
|
+ "sessions",
|
|
|
+ "memory",
|
|
|
+ "memories",
|
|
|
+ "hook",
|
|
|
+ "hooks",
|
|
|
+ "gateway",
|
|
|
+ "cron",
|
|
|
+ "config",
|
|
|
+ "commands",
|
|
|
+ "help",
|
|
|
+ "version",
|
|
|
+ "update",
|
|
|
+ "exec",
|
|
|
+ "run",
|
|
|
+ "send",
|
|
|
+ "message",
|
|
|
+ "search",
|
|
|
+ "fetch",
|
|
|
+ "schedule",
|
|
|
+ "install",
|
|
|
+ "uninstall",
|
|
|
+ "refresh",
|
|
|
+ "save",
|
|
|
+ "list",
|
|
|
+ "deploy",
|
|
|
+ "upload"
|
|
|
+].map((cmd) => cmd.toLowerCase());
|
|
|
+const STATIC_COMMANDS = loadCommandList();
|
|
|
+const COMMAND_BLACKLIST = new Set(STATIC_COMMANDS);
|
|
|
+const ALIAS_BLACKLIST = buildAliasBlacklist();
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Config — openclaw does NOT inject cfg into hook events.
|
|
|
@@ -58,6 +105,63 @@ function loadPluginCfg() {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+function loadCommandList(): string[] {
|
|
|
+ try {
|
|
|
+ const raw = fs.readFileSync(COMMAND_LIST_PATH, "utf8");
|
|
|
+ const parsed = JSON.parse(raw);
|
|
|
+ if (Array.isArray(parsed)) {
|
|
|
+ return parsed
|
|
|
+ .map((item) => String(item || "").trim().toLowerCase())
|
|
|
+ .filter(Boolean);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[mem0] failed to load command list, using defaults:", err?.message || err);
|
|
|
+ }
|
|
|
+ return DEFAULT_COMMANDS;
|
|
|
+}
|
|
|
+
|
|
|
+function buildAliasBlacklist(): Set<string> {
|
|
|
+ const aliases = new Set<string>();
|
|
|
+ try {
|
|
|
+ const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
|
|
|
+ const config = JSON.parse(raw);
|
|
|
+ const addAlias = (alias: unknown) => {
|
|
|
+ if (typeof alias === "string" && alias.trim()) {
|
|
|
+ aliases.add(alias.trim().toLowerCase());
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const addModelAliases = (models: Record<string, any> | undefined) => {
|
|
|
+ if (!models) return;
|
|
|
+ for (const modelConfig of Object.values(models)) {
|
|
|
+ addAlias(modelConfig?.alias);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ addModelAliases(config?.agents?.defaults?.models);
|
|
|
+ if (Array.isArray(config?.agents?.list)) {
|
|
|
+ for (const agent of config.agents.list) {
|
|
|
+ addModelAliases(agent?.models);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[mem0] could not build alias blacklist:", err?.message || err);
|
|
|
+ }
|
|
|
+ return aliases;
|
|
|
+}
|
|
|
+
|
|
|
+function extractSlashToken(text: string): string | null {
|
|
|
+ const trimmed = text.trim();
|
|
|
+ if (!trimmed.startsWith("/")) return null;
|
|
|
+ const token = trimmed.slice(1).split(/\s+/)[0];
|
|
|
+ if (!token) return null;
|
|
|
+ return token.toLowerCase();
|
|
|
+}
|
|
|
+
|
|
|
+function isSlashCommand(text: string): boolean {
|
|
|
+ const token = extractSlashToken(text);
|
|
|
+ if (!token) return false;
|
|
|
+ return COMMAND_BLACKLIST.has(token) || ALIAS_BLACKLIST.has(token);
|
|
|
+}
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Types
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -76,6 +180,7 @@ interface MemoryResult {
|
|
|
score: number;
|
|
|
rerank_score: number;
|
|
|
metadata?: Record<string, any>;
|
|
|
+ created_at?: string;
|
|
|
}
|
|
|
|
|
|
interface SearchResponse {
|
|
|
@@ -250,6 +355,27 @@ function formatKnowledgeCitation(meta: Record<string, any> = {}): string {
|
|
|
return parts.length > 0 ? `(from: ${parts.join(", ")})` : "";
|
|
|
}
|
|
|
|
|
|
+function formatMemoryAge(createdAt?: string): string | null {
|
|
|
+ if (!createdAt) return null;
|
|
|
+ const epoch = Date.parse(createdAt);
|
|
|
+ if (Number.isNaN(epoch)) return null;
|
|
|
+ const deltaMs = Date.now() - epoch;
|
|
|
+ if (deltaMs < 0) return null;
|
|
|
+ const minutes = Math.floor(deltaMs / 60_000);
|
|
|
+ if (minutes < 1) return "[just now]";
|
|
|
+ if (minutes < 60) return `[${minutes}m ago]`;
|
|
|
+ const hours = Math.floor(minutes / 60);
|
|
|
+ if (hours < 24) return `[${hours}h ago]`;
|
|
|
+ const days = Math.floor(hours / 24);
|
|
|
+ if (days < 7) return `[${days}d ago]`;
|
|
|
+ const weeks = Math.floor(days / 7);
|
|
|
+ if (weeks < 4) return `[${weeks}w ago]`;
|
|
|
+ const months = Math.floor(days / 30);
|
|
|
+ if (months < 12) return `[${months}mo ago]`;
|
|
|
+ const years = Math.floor(days / 365);
|
|
|
+ return `[${years}y ago]`;
|
|
|
+}
|
|
|
+
|
|
|
function buildInjectionBlock(
|
|
|
personalResults: MemoryResult[] | null,
|
|
|
knowledgeResults: MemoryResult[] | null,
|
|
|
@@ -266,7 +392,11 @@ function buildInjectionBlock(
|
|
|
// endpoint was down — omit the section entirely, already logged
|
|
|
} else if (personal.length > 0) {
|
|
|
const lines = personal
|
|
|
- .map((r) => `- ${r.memory}`)
|
|
|
+ .map((r) => {
|
|
|
+ const age = formatMemoryAge(r.created_at);
|
|
|
+ const prefix = age ? `${age} ` : "";
|
|
|
+ return `- ${prefix}${r.memory}`;
|
|
|
+ })
|
|
|
.join("\n");
|
|
|
sections.push(`[MEMORY - Personal]\n${lines}`);
|
|
|
}
|
|
|
@@ -284,7 +414,10 @@ function buildInjectionBlock(
|
|
|
const lines = knowledge
|
|
|
.map((r) => {
|
|
|
const citation = formatKnowledgeCitation(r.metadata || {});
|
|
|
- return citation ? `- ${citation} ${r.memory}` : `- ${r.memory}`;
|
|
|
+ const age = formatMemoryAge(r.created_at);
|
|
|
+ const prefix = age ? `${age} ` : "";
|
|
|
+ const body = citation ? `${citation} ${r.memory}` : r.memory;
|
|
|
+ return `- ${prefix}${body}`;
|
|
|
})
|
|
|
.join("\n");
|
|
|
sections.push(`[MEMORY - Knowledge Base]\n${lines}`);
|
|
|
@@ -385,12 +518,13 @@ export default async function handler(event: HookEvent) {
|
|
|
else if (typeof m?.text === "string") text = m.text.trim();
|
|
|
else if (typeof m?.body === "string") text = m.body.trim();
|
|
|
}
|
|
|
- return text;
|
|
|
+ return text.trim();
|
|
|
}
|
|
|
|
|
|
// ── Auto-recall: queries both endpoints in parallel ───────────────────────
|
|
|
const runAutoRecall = async (text: string) => {
|
|
|
if (!pluginCfg.autoRecall) return;
|
|
|
+ if (!text || isSlashCommand(text)) return;
|
|
|
|
|
|
const {
|
|
|
baseUrl,
|
|
|
@@ -431,7 +565,7 @@ export default async function handler(event: HookEvent) {
|
|
|
if (event.action === "received") {
|
|
|
if (!pluginCfg.autoCapture) return;
|
|
|
const text = resolveText();
|
|
|
- if (!text) return;
|
|
|
+ if (!text || isSlashCommand(text)) return;
|
|
|
if (isMediaPlaceholder(text) && !transcriptNow) return;
|
|
|
|
|
|
const recent = pushRecent(
|
|
|
@@ -452,6 +586,7 @@ export default async function handler(event: HookEvent) {
|
|
|
if (!shouldCapture) return;
|
|
|
|
|
|
const captureText = recent.join("\n");
|
|
|
+ if (!captureText.trim()) return;
|
|
|
const hash = simpleHash(captureText);
|
|
|
const lastCapture = recentlyCaptured.get(hash);
|
|
|
if (lastCapture && Date.now() - lastCapture < CAPTURE_DEDUP_MS) {
|
|
|
@@ -488,7 +623,7 @@ export default async function handler(event: HookEvent) {
|
|
|
// ── preprocessed: recall ─────────────────────────────────────────────────
|
|
|
if (event.action === "preprocessed") {
|
|
|
const text = resolveText();
|
|
|
- if (!text) return;
|
|
|
+ if (!text || isSlashCommand(text)) return;
|
|
|
if (!transcriptNow && isMediaPlaceholder(text)) return;
|
|
|
await runAutoRecall(text);
|
|
|
return;
|
|
|
@@ -496,8 +631,8 @@ export default async function handler(event: HookEvent) {
|
|
|
|
|
|
// ── transcribed: recall ──────────────────────────────────────────────────
|
|
|
if (event.action === "transcribed") {
|
|
|
- const text = extractMessageText(event.context);
|
|
|
- if (!text) return;
|
|
|
+ const text = extractMessageText(event.context).trim();
|
|
|
+ if (!text || isSlashCommand(text)) return;
|
|
|
await runAutoRecall(text);
|
|
|
}
|
|
|
}
|