From 3c0083d74ad7c5339efa09731b5b3047ea07f5d9 Mon Sep 17 00:00:00 2001 From: jiang Date: Mon, 23 Mar 2026 17:02:24 +0800 Subject: [PATCH 1/2] feat: add install scripts --- apps/memos-local-openclaw/install.ps1 | 383 ++++++++++++++++++++++++++ apps/memos-local-openclaw/install.sh | 294 ++++++++++++++++++++ 2 files changed, 677 insertions(+) create mode 100644 apps/memos-local-openclaw/install.ps1 create mode 100644 apps/memos-local-openclaw/install.sh diff --git a/apps/memos-local-openclaw/install.ps1 b/apps/memos-local-openclaw/install.ps1 new file mode 100644 index 000000000..c78e894dd --- /dev/null +++ b/apps/memos-local-openclaw/install.ps1 @@ -0,0 +1,383 @@ +$ErrorActionPreference = "Stop" +if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) { + $PSNativeCommandUseErrorActionPreference = $false +} +$env:NPM_CONFIG_LOGLEVEL = "error" + +function Write-Info { + param([string]$Message) + Write-Host $Message -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host $Message -ForegroundColor Green +} + +function Write-Warn { + param([string]$Message) + Write-Host $Message -ForegroundColor Yellow +} + +function Write-Err { + param([string]$Message) + Write-Host $Message -ForegroundColor Red +} + +function Get-NodeMajorVersion { + $nodeCommand = Get-Command node -ErrorAction SilentlyContinue + if (-not $nodeCommand) { + return 0 + } + $versionRaw = & node -v 2>$null + if (-not $versionRaw) { + return 0 + } + $trimmed = $versionRaw.TrimStart("v") + $majorText = $trimmed.Split(".")[0] + $major = 0 + if ([int]::TryParse($majorText, [ref]$major)) { + return $major + } + return 0 +} + +function Update-SessionPath { + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = "$machinePath;$userPath" +} + +function Install-Node { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Err "winget is required for automatic Node.js installation on Windows." + Write-Err "Install Node.js 22 or newer manually from https://nodejs.org and rerun this script." + exit 1 + } + + Write-Info "Installing Node.js via winget..." + & winget install OpenJS.NodeJS --accept-package-agreements --accept-source-agreements --silent + Update-SessionPath +} + +function Ensure-Node22 { + $requiredMajor = 22 + $currentMajor = Get-NodeMajorVersion + if ($currentMajor -ge $requiredMajor) { + Write-Success "Node.js version check passed (>= $requiredMajor)." + return + } + + Write-Warn "Node.js >= $requiredMajor is required." + Write-Warn "Node.js is missing or too old. Starting automatic installation..." + Install-Node + + $currentMajor = Get-NodeMajorVersion + if ($currentMajor -ge $requiredMajor) { + $currentVersion = & node -v + Write-Success "Node.js is ready: $currentVersion" + return + } + + Write-Err "Node.js installation did not meet version >= $requiredMajor." + exit 1 +} + +function Print-Banner { + Write-Host "Memos Local OpenClaw Installer" -ForegroundColor Cyan + Write-Host "Memos Local Memory for OpenClaw." -ForegroundColor Cyan + Write-Host "Keep your context, tasks, and recall in one local memory engine." -ForegroundColor Yellow +} + +function Parse-Arguments { + param([string[]]$RawArgs) + + $result = @{ + PluginVersion = "latest" + Port = "18789" + OpenClawHome = (Join-Path $HOME ".openclaw") + } + + $index = 0 + while ($index -lt $RawArgs.Count) { + $arg = $RawArgs[$index] + switch ($arg) { + "--version" { + if ($index + 1 -ge $RawArgs.Count) { + Write-Err "Missing value for --version." + exit 1 + } + $result.PluginVersion = $RawArgs[$index + 1] + $index += 2 + } + "--port" { + if ($index + 1 -ge $RawArgs.Count) { + Write-Err "Missing value for --port." + exit 1 + } + $result.Port = $RawArgs[$index + 1] + $index += 2 + } + "--openclaw-home" { + if ($index + 1 -ge $RawArgs.Count) { + Write-Err "Missing value for --openclaw-home." + exit 1 + } + $result.OpenClawHome = $RawArgs[$index + 1] + $index += 2 + } + default { + Write-Err "Unknown argument: $arg" + Write-Warn "Usage: .\apps\install.ps1 [--version ] [--port ] [--openclaw-home ]" + exit 1 + } + } + } + + if ([string]::IsNullOrWhiteSpace($result.PluginVersion) -or + [string]::IsNullOrWhiteSpace($result.Port) -or + [string]::IsNullOrWhiteSpace($result.OpenClawHome)) { + Write-Err "Arguments cannot be empty." + exit 1 + } + + return $result +} + +function Update-OpenClawConfig { + param( + [string]$OpenClawHome, + [string]$ConfigPath, + [string]$PluginId + ) + + Write-Info "Updating OpenClaw config..." + New-Item -ItemType Directory -Path $OpenClawHome -Force | Out-Null + $nodeScript = @' +const fs = require("fs"); + +const configPath = process.argv[2]; +const pluginId = process.argv[3]; + +let config = {}; +if (fs.existsSync(configPath)) { + const raw = fs.readFileSync(configPath, "utf8").trim(); + if (raw.length > 0) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + config = parsed; + } + } +} + +if (!config.plugins || typeof config.plugins !== "object" || Array.isArray(config.plugins)) { + config.plugins = {}; +} + +config.plugins.enabled = true; + +if (!Array.isArray(config.plugins.allow)) { + config.plugins.allow = []; +} + +if (!config.plugins.allow.includes(pluginId)) { + config.plugins.allow.push(pluginId); +} + +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +'@ + $nodeScript | & node - $ConfigPath $PluginId + Write-Success "OpenClaw config updated: $ConfigPath" +} + +function Ensure-PluginDirRemovedByUninstall { + param([string]$ExtensionDir, [string]$PluginId) + + $preservedDistPath = "" + if (Test-Path $ExtensionDir) { + Write-Warn "Plugin directory still exists after uninstall: $ExtensionDir" + Write-Warn "Preparing plugin directory for reinstall while preserving dist..." + + $distPath = Join-Path $ExtensionDir "dist" + if (Test-Path $distPath) { + $backupRoot = Join-Path $env:TEMP ("memos-local-openclaw-dist-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null + $preservedDistPath = Join-Path $backupRoot "dist" + try { + Move-Item -LiteralPath $distPath -Destination $preservedDistPath -Force -ErrorAction Stop + Write-Info "Preserved dist for reinstall: $preservedDistPath" + } + catch { + Write-Err "Failed to preserve dist directory before cleanup." + Write-Err $_.Exception.Message + exit 1 + } + } + + $items = Get-ChildItem -LiteralPath $ExtensionDir -Force -ErrorAction SilentlyContinue + foreach ($item in $items) { + try { + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop + } + catch { + Write-Err "Failed to remove leftover item: $($item.FullName)" + Write-Err $_.Exception.Message + exit 1 + } + } + + $remaining = Get-ChildItem -LiteralPath $ExtensionDir -Force -ErrorAction SilentlyContinue + $nonDistRemaining = @($remaining | Where-Object { $_.Name -ine "dist" }) + if ($nonDistRemaining.Count -gt 0) { + Write-Err "Leftover files still exist after cleanup." + $nonDistRemaining | ForEach-Object { Write-Host $_.FullName } + exit 1 + } + + try { + Remove-Item -LiteralPath $ExtensionDir -Recurse -Force -ErrorAction Stop + } + catch { + Write-Err "Failed to remove plugin directory before reinstall: $ExtensionDir" + Write-Err $_.Exception.Message + exit 1 + } + + if (Test-Path $ExtensionDir) { + Write-Err "Plugin directory still exists before reinstall: $ExtensionDir" + exit 1 + } + + Write-Success "Plugin directory prepared for reinstall." + } + + return $preservedDistPath +} + +function Restore-PreservedDistIfNeeded { + param([string]$PreservedDistPath, [string]$ExtensionDir) + if ([string]::IsNullOrWhiteSpace($PreservedDistPath) -or -not (Test-Path $PreservedDistPath)) { + return + } + + $targetDistPath = Join-Path $ExtensionDir "dist" + $backupRoot = Split-Path -Path $PreservedDistPath -Parent + try { + if (Test-Path $targetDistPath) { + $legacyDistPath = Join-Path $ExtensionDir ("dist_preserved_" + (Get-Date -Format "yyyyMMddHHmmss")) + Move-Item -LiteralPath $PreservedDistPath -Destination $legacyDistPath -Force -ErrorAction Stop + Write-Warn "Installer created a new dist. Previous dist was preserved at: $legacyDistPath" + } + else { + Move-Item -LiteralPath $PreservedDistPath -Destination $targetDistPath -Force -ErrorAction Stop + Write-Success "Restored preserved dist to: $targetDistPath" + } + } + catch { + Write-Err "Failed to restore preserved dist." + Write-Err $_.Exception.Message + exit 1 + } + finally { + if (Test-Path $backupRoot) { + Remove-Item -LiteralPath $backupRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Uninstall-PluginIfPresent { + param([string]$PluginId) + + $outputLines = @() + try { + $outputLines = "y`n" | & npx openclaw plugins uninstall $PluginId 2>&1 + } + catch { + $outputLines += ($_ | Out-String) + } + + $outputText = ($outputLines | Out-String) + if (-not [string]::IsNullOrWhiteSpace($outputText) -and ($outputText -notmatch "Plugin not found")) { + Write-Warn "Uninstall returned messages and will be ignored to match install.sh behavior." + } + Write-Info "Uninstall step completed (best effort)." +} + +$parsed = Parse-Arguments -RawArgs $args +$PluginVersion = $parsed.PluginVersion +$Port = $parsed.Port +$OpenClawHome = $parsed.OpenClawHome + +$PluginId = "memos-local-openclaw-plugin" +$PluginPackage = "@memtensor/memos-local-openclaw-plugin" +$PackageSpec = "$PluginPackage@$PluginVersion" +$ExtensionDir = Join-Path $OpenClawHome "extensions\$PluginId" +$OpenClawConfigPath = Join-Path $OpenClawHome "openclaw.json" + +Print-Banner +Ensure-Node22 + +if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + Write-Err "npx was not found after Node.js setup." + exit 1 +} + +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Err "npm was not found after Node.js setup." + exit 1 +} + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Err "node was not found after setup." + exit 1 +} + +Write-Info "Stopping OpenClaw Gateway..." +try { + & npx openclaw gateway stop *> $null +} +catch { + Write-Warn "OpenClaw gateway stop returned an error. Continuing..." +} + +$portNumber = 0 +if ([int]::TryParse($Port, [ref]$portNumber)) { + $connections = Get-NetTCPConnection -LocalPort $portNumber -ErrorAction SilentlyContinue + if ($connections) { + $pids = $connections | Select-Object -ExpandProperty OwningProcess -Unique + if ($pids) { + Write-Warn "Processes still using port $Port. Killing PID(s): $($pids -join ', ')" + foreach ($processId in $pids) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + } + } +} + +Write-Info "Uninstalling existing plugin if present..." +Uninstall-PluginIfPresent -PluginId $PluginId +$preservedDistPath = Ensure-PluginDirRemovedByUninstall -ExtensionDir $ExtensionDir -PluginId $PluginId + +Write-Info "Installing plugin $PackageSpec..." +& npx openclaw plugins install $PackageSpec + +if (-not (Test-Path $ExtensionDir)) { + Write-Err "Plugin directory was not found: $ExtensionDir" + exit 1 +} + +Restore-PreservedDistIfNeeded -PreservedDistPath $preservedDistPath -ExtensionDir $ExtensionDir + +Write-Info "Rebuilding better-sqlite3..." +Push-Location $ExtensionDir +try { + & npm rebuild better-sqlite3 +} +finally { + Pop-Location +} + +Update-OpenClawConfig -OpenClawHome $OpenClawHome -ConfigPath $OpenClawConfigPath -PluginId $PluginId + +Write-Success "Restarting OpenClaw Gateway..." +& npx openclaw gateway run --port $Port --force diff --git a/apps/memos-local-openclaw/install.sh b/apps/memos-local-openclaw/install.sh new file mode 100644 index 000000000..deb027a48 --- /dev/null +++ b/apps/memos-local-openclaw/install.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' +DEFAULT_TAGLINE="Memos Local Memory for OpenClaw." +DEFAULT_SUBTITLE="Keep your context, tasks, and recall in one local memory engine." + +info() { + echo -e "${BLUE}$1${NC}" +} + +success() { + echo -e "${GREEN}$1${NC}" +} + +warn() { + echo -e "${YELLOW}$1${NC}" +} + +error() { + echo -e "${RED}$1${NC}" +} + +node_major_version() { + if ! command -v node >/dev/null 2>&1; then + echo "0" + return 0 + fi + local node_version + node_version="$(node -v 2>/dev/null || true)" + node_version="${node_version#v}" + echo "${node_version%%.*}" +} + +run_with_privilege() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + else + sudo "$@" + fi +} + +download_to_file() { + local url="$1" + local output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL --proto '=https' --tlsv1.2 "$url" -o "$output" + return 0 + fi + if command -v wget >/dev/null 2>&1; then + wget -q --https-only --secure-protocol=TLSv1_2 "$url" -O "$output" + return 0 + fi + return 1 +} + +install_node22() { + local os_name + os_name="$(uname -s)" + + if [[ "$os_name" == "Darwin" ]]; then + if ! command -v brew >/dev/null 2>&1; then + error "Homebrew is required to auto-install Node.js on macOS, macOS 自动安装 Node.js 需要 Homebrew" + error "Install Homebrew first, 请先安装 Homebrew: https://brew.sh" + exit 1 + fi + info "Auto install Node.js 22 via Homebrew, 通过 Homebrew 自动安装 Node.js 22..." + brew install node@22 >/dev/null + brew link node@22 --overwrite --force >/dev/null 2>&1 || true + local brew_node_prefix + brew_node_prefix="$(brew --prefix node@22 2>/dev/null || true)" + if [[ -n "$brew_node_prefix" && -x "${brew_node_prefix}/bin/node" ]]; then + export PATH="${brew_node_prefix}/bin:${PATH}" + fi + return 0 + fi + + if [[ "$os_name" == "Linux" ]]; then + info "Auto install Node.js 22 on Linux, 在 Linux 自动安装 Node.js 22..." + local tmp_script + tmp_script="$(mktemp)" + if command -v apt-get >/dev/null 2>&1; then + if ! download_to_file "https://deb.nodesource.com/setup_22.x" "$tmp_script"; then + error "Failed to download NodeSource setup script, 下载 NodeSource 脚本失败" + rm -f "$tmp_script" + exit 1 + fi + run_with_privilege bash "$tmp_script" + run_with_privilege apt-get update -qq + run_with_privilege apt-get install -y -qq nodejs + rm -f "$tmp_script" + return 0 + fi + if command -v dnf >/dev/null 2>&1; then + if ! download_to_file "https://rpm.nodesource.com/setup_22.x" "$tmp_script"; then + error "Failed to download NodeSource setup script, 下载 NodeSource 脚本失败" + rm -f "$tmp_script" + exit 1 + fi + run_with_privilege bash "$tmp_script" + run_with_privilege dnf install -y -q nodejs + rm -f "$tmp_script" + return 0 + fi + if command -v yum >/dev/null 2>&1; then + if ! download_to_file "https://rpm.nodesource.com/setup_22.x" "$tmp_script"; then + error "Failed to download NodeSource setup script, 下载 NodeSource 脚本失败" + rm -f "$tmp_script" + exit 1 + fi + run_with_privilege bash "$tmp_script" + run_with_privilege yum install -y -q nodejs + rm -f "$tmp_script" + return 0 + fi + rm -f "$tmp_script" + fi + + error "Unsupported platform for auto-install, 当前平台不支持自动安装 Node.js 22" + error "Please install Node.js >=22 manually, 请手动安装 Node.js >=22" + exit 1 +} + +ensure_node22() { + local required_major="22" + local current_major + current_major="$(node_major_version)" + + if [[ "$current_major" =~ ^[0-9]+$ ]] && (( current_major >= required_major )); then + success "Node.js version check passed (>= ${required_major}), Node.js 版本检查通过 (>= ${required_major})" + return 0 + fi + + warn "Node.js >= ${required_major} is required, 需要 Node.js >= ${required_major}" + warn "Current Node.js is too old or missing, 当前 Node.js 版本过低或不存在,开始自动安装..." + install_node22 + + current_major="$(node_major_version)" + if [[ "$current_major" =~ ^[0-9]+$ ]] && (( current_major >= required_major )); then + success "Node.js upgraded and ready, Node.js 已升级并可用: $(node -v)" + return 0 + fi + + error "Node.js installation did not meet >= ${required_major}, Node.js 安装后仍不满足 >= ${required_major}" + exit 1 +} + +print_banner() { + echo -e "${BLUE}${BOLD}🧠 Memos Local OpenClaw Installer${NC}" + echo -e "${BLUE}${DEFAULT_TAGLINE}${NC}" + echo -e "${YELLOW}${DEFAULT_SUBTITLE}${NC}" +} + +PLUGIN_ID="memos-local-openclaw-plugin" +PLUGIN_PACKAGE="@memtensor/memos-local-openclaw-plugin" +PLUGIN_VERSION="latest" +PORT="18789" +OPENCLAW_HOME="${HOME}/.openclaw" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + PLUGIN_VERSION="${2:-}" + shift 2 + ;; + --port) + PORT="${2:-}" + shift 2 + ;; + --openclaw-home) + OPENCLAW_HOME="${2:-}" + shift 2 + ;; + *) + error "Unknown argument, 未知参数: $1" + warn "Usage, 用法: bash apps/openclaw-memos-plugin-install.sh [--version <版本>] [--port <端口>] [--openclaw-home <路径>]" + exit 1 + ;; + esac +done + +if [[ -z "$PLUGIN_VERSION" || -z "$PORT" || -z "$OPENCLAW_HOME" ]]; then + error "Arguments cannot be empty, 参数不能为空" + exit 1 +fi + +print_banner + +ensure_node22 + +if ! command -v npx >/dev/null 2>&1; then + error "npx not found after Node.js setup, Node.js 安装后仍未找到 npx" + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + error "npm not found after Node.js setup, Node.js 安装后仍未找到 npm" + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + error "node not found after setup, 环境初始化后仍未找到 node" + exit 1 +fi + +PACKAGE_SPEC="${PLUGIN_PACKAGE}@${PLUGIN_VERSION}" +EXTENSION_DIR="${OPENCLAW_HOME}/extensions/${PLUGIN_ID}" +OPENCLAW_CONFIG_PATH="${OPENCLAW_HOME}/openclaw.json" + +update_openclaw_config() { + info "Update OpenClaw config, 更新 OpenClaw 配置..." + mkdir -p "${OPENCLAW_HOME}" + node - "${OPENCLAW_CONFIG_PATH}" "${PLUGIN_ID}" <<'NODE' +const fs = require('fs'); + +const configPath = process.argv[2]; +const pluginId = process.argv[3]; + +let config = {}; +if (fs.existsSync(configPath)) { + const raw = fs.readFileSync(configPath, 'utf8').trim(); + if (raw.length > 0) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed; + } + } +} + +if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) { + config.plugins = {}; +} + +config.plugins.enabled = true; + +if (!Array.isArray(config.plugins.allow)) { + config.plugins.allow = []; +} + +if (!config.plugins.allow.includes(pluginId)) { + config.plugins.allow.push(pluginId); +} + +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); +NODE + success "OpenClaw config updated, OpenClaw 配置已更新: ${OPENCLAW_CONFIG_PATH}" +} + +ensure_plugin_dir_removed_by_uninstall() { + if [[ -d "${EXTENSION_DIR}" ]]; then + error "Plugin directory still exists after uninstall, 卸载后插件目录仍存在: ${EXTENSION_DIR}" + warn "Run this command and retry, 请先执行以下命令后重试: echo \"y\" | npx openclaw plugins uninstall ${PLUGIN_ID}" + exit 1 + fi +} + +info "Stop OpenClaw Gateway, 停止 OpenClaw Gateway..." +npx openclaw gateway stop >/dev/null 2>&1 || true + +if command -v lsof >/dev/null 2>&1; then + PIDS="$(lsof -i :"${PORT}" -t 2>/dev/null || true)" + if [[ -n "$PIDS" ]]; then + warn "Processes still on port ${PORT}, 检测到端口 ${PORT} 仍有进程,占用 PID: ${PIDS}" + echo "$PIDS" | xargs kill -9 >/dev/null 2>&1 || true + fi +fi + +info "Uninstall old plugin if exists, 卸载旧插件(若存在)..." +printf "y\n" | npx openclaw plugins uninstall "${PLUGIN_ID}" >/dev/null 2>&1 || true +ensure_plugin_dir_removed_by_uninstall + +info "Install plugin ${PACKAGE_SPEC}, 安装插件 ${PACKAGE_SPEC}..." +npx openclaw plugins install "${PACKAGE_SPEC}" + +if [[ ! -d "$EXTENSION_DIR" ]]; then + error "Plugin directory not found, 未找到插件目录: ${EXTENSION_DIR}" + exit 1 +fi + +info "Rebuild better-sqlite3, 重编译 better-sqlite3..." +( + cd "$EXTENSION_DIR" + npm rebuild better-sqlite3 +) + +update_openclaw_config + +success "Restart OpenClaw Gateway, 重启 OpenClaw Gateway..." +exec npx openclaw gateway run --port "${PORT}" --force From 629adb9ecd26c8bd499129f0fdf5cab9d0e970d5 Mon Sep 17 00:00:00 2001 From: jiang Date: Mon, 23 Mar 2026 20:07:39 +0800 Subject: [PATCH 2/2] fix: move memory from user prompt to system prompt --- apps/memos-local-openclaw/index.ts | 54 +++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index b9ca99552..1cecbdf02 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -1779,6 +1779,31 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, { name: "network_skill_pull" }, ); + // ─── Inject recall context as system message (hidden from chat UI) ─── + // `prependContext` (from before_agent_start) is prepended to user messages and + // therefore visible in the chat box. To keep injected memories invisible to the + // user while still feeding them to the model, we stash the recall result in + // `pendingRecallContext` and inject it as a system-level message via the + // `before_context_send` hook, which fires synchronously during prompt assembly. + let pendingRecallContext: string | null = null; + + api.on("before_context_send", (event: { messages: Array<{ role: string; content: string | unknown }> }) => { + if (!pendingRecallContext) return; + const memoryContext = pendingRecallContext; + pendingRecallContext = null; + // Insert after the last system message (before the first user/assistant turn). + // This keeps the static system prompt at the very beginning of the sequence so + // KV-cache prefixes stay stable across requests — the provider can reuse the + // cached keys/values for the system prompt even though the memory block changes. + const firstNonSystemIdx = event.messages.findIndex((m) => m.role !== "system"); + if (firstNonSystemIdx === -1) { + event.messages.push({ role: "system", content: memoryContext }); + } else { + event.messages.splice(firstNonSystemIdx, 0, { role: "system", content: memoryContext }); + } + ctx.log.info(`before_context_send: injected recall context as system message at idx=${firstNonSystemIdx === -1 ? event.messages.length - 1 : firstNonSystemIdx} (${memoryContext.length} chars)`); + }); + // ─── Auto-recall: inject relevant memories before agent starts ─── api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => { @@ -1884,7 +1909,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task."; ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`); try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ } - return { prependContext: skillContext }; + pendingRecallContext = skillContext; + return {}; } } catch (err) { ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`); @@ -1893,11 +1919,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, if (query.length > 50) { const noRecallHint = - "## Memory system — ACTION REQUIRED\n\n" + + "## Memory system\n\n" + "Auto-recall found no results for a long query. " + - "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + - "Do NOT skip this step. Do NOT answer without searching first."; - return { prependContext: noRecallHint }; + "Call `memory_search` with a shortened query (2-5 key words) before answering."; + pendingRecallContext = noRecallHint; + return {}; } return; } @@ -1933,7 +1959,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, }), dur, true); if (query.length > 50) { const noRecallHint = - "## Memory system — ACTION REQUIRED\n\n" + + "## Memory system\n\n" + "Auto-recall found no relevant results for a long query. " + "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + "Do NOT skip this step. Do NOT answer without searching first."; @@ -1985,11 +2011,9 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n"); const contextParts = [ - "## User's conversation history (from memory system)", + "## Recalled memories", "", - "IMPORTANT: The following are facts from previous conversations with this user.", - "You MUST treat these as established knowledge and use them directly when answering.", - "Do NOT say you don't know or don't have information if the answer is in these memories.", + "The following facts were retrieved from previous conversations with this user. Treat them as established knowledge.", "", lines.join("\n\n"), ]; @@ -2073,18 +2097,18 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, }), recallDur, true); telemetry.trackAutoRecall(filteredHits.length, recallDur); - ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`); + ctx.log.info(`auto-recall: stashing recall context for system message injection (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`); if (!sufficient) { const searchHint = "\n\nIf these memories don't fully answer the question, " + "call `memory_search` with a shorter or rephrased query to find more."; - return { prependContext: context + searchHint }; + pendingRecallContext = context + searchHint; + return {}; } - return { - prependContext: context, - }; + pendingRecallContext = context; + return {}; } catch (err) { const dur = performance.now() - recallT0; store.recordToolCall("memory_search", dur, false);