diff --git a/bmem/SKILL.md b/bmem/SKILL.md index 01e6628..e893a0b 100644 --- a/bmem/SKILL.md +++ b/bmem/SKILL.md @@ -1,6 +1,6 @@ --- name: bmem -description: 用 bmem 记忆系统读写跨机器、跨 agent 的长期记忆——判断一条信息该进全局/项目/机器/log 哪一层、把 ./Notes 下的 PRxx/Taskxx/Resultxx 导入记忆、会话收尾该沉淀什么、怎么查以前做过什么、以及 bmem 命令报错时怎么修。当用户提到"记住"、"记一下"、"存到记忆里"、"之前/以前是不是做过"、"上次怎么解决的"、"查一下历史"、"这台机器装了什么"、"这个项目在别的机器上在哪"、"账号密码记下来"、"同步记忆"、"这个项目不做了/从记忆里删掉"、"bmem"、"记忆系统",或者在一个根目录有 bmem_.md 的项目里开始实质性工作、需要知道过去的决策和踩过的坑时,必须使用此技能——即使用户没有说"skill"或"bmem"两个字。用户改完 ./Notes 下的需求/方案/结果文档、或一段实质性工作收尾时也要用它。bmem 命令报错(未加入 bmem / 不在项目内 / PG 不可达 / git 冲突 / 重复注册)时同样用它。 +description: 用 bmem 管理跨机器长期记忆、导入 Notes,并处理 bmem sync 或 Gitea 同步故障;当用户要求记住、查历史、同步记忆或排障时使用。 --- # bmem:Claude Code 与 Codex 共享的跨机器长期记忆 @@ -192,6 +192,14 @@ bmem add --machine --section "账号/服务配置" "gitea 账号 bowei,凭证 `bmem sync` 做两件事:git 同步三层 Markdown(工作副本 ↔ `~/.bmem/repo` clone ↔ gitea), 以及与中心 PostgreSQL 双向同步 log 层。**它只碰 bmem 自己的文件,不会动你项目的 git 分支。** +## `bmem sync` 的 Gitea 故障:立即修复并实测闭环 + +`bmem sync` 的 Git 层失败时先判型。若 refs/API 仍可读而 clone/fetch 报 `bad pack header`、`early EOF`、`RPC failed` 或 `git-upload-pack` 异常,这是 Gitea pack 传输故障;**当轮立即使用 `tools-sync` skill 阶段 1.5 登录服务器诊断并修复**,不要只重试 `bmem sync`、只报“远端待恢复”,或把 PostgreSQL 成功当成整个同步成功。 + +1. 从 `~/.bmem/repo` 的 `origin` 取得 bmem 仓库 URL;运行 `tools-sync/scripts/gitea_doctor.ps1 -RepoUrl ` 确认 refs 与真正的浅克隆两个面,然后 `-Fix`。服务器地址、容器名、SSH 主机指纹取自本机 `~/skills_env.md`,凭据只从本机已授权的凭据存储或 bmem 既有记录读取,绝不输出、硬编码到 skill/仓库或提交。若 OpenSSH 私钥缺失,doctor 应尝试已登记密码和固定主机指纹的 PuTTY 通道;仍不能登录时立即说明缺的是哪种访问能力并请求用户提供,不能声称完成。 +2. 在服务端先核对精确目标,再对异常全局 Git 配置或注入脚本做可恢复隔离、重启并重新检查;不进行强推、仓库手术或不可恢复清理,除非另有明确授权。修复后的判断依据是**从客户端真正完成 fetch/clone**,不是 Gitea 网页、`ls-remote` 或容器自称 healthy。 +3. 立刻重跑 `bmem sync`。传输恢复后若出现真实 rebase 冲突,保留本地和远端语义,解决 `~/.bmem/repo` 及会重新覆盖仓库的本机 Markdown 工作副本,特别核对自动编号唯一;只做非强制推送,再次运行 `bmem sync`。 +4. 只有 `bmem sync` 返回成功、Git 拉取/推送成功、远端 `main` 与本地提交相同、客户端浅克隆可读,才报告 Markdown 层已同步;PostgreSQL 另按自身结果报告。若新故障持续,继续做安全范围内的诊断;真正缺少登录权限或需要额外授权时报告具体阻塞和所需输入,不把未同步状态遗留为“已完成”。 ## 多机与本机状态 ```bash diff --git a/tools-sync/scripts/gitea_doctor.ps1 b/tools-sync/scripts/gitea_doctor.ps1 index 138ff5b..97bdac5 100644 --- a/tools-sync/scripts/gitea_doctor.ps1 +++ b/tools-sync/scripts/gitea_doctor.ps1 @@ -77,7 +77,15 @@ function Test-Transport { $out = & git -c core.askPass=true clone --quiet --bare --depth 1 --filter=tree:0 $RepoUrl $tmp 2>&1 | ForEach-Object { "$_" } $code = $LASTEXITCODE $ErrorActionPreference = $prev - if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) + $resolvedTmp = [IO.Path]::GetFullPath($tmp) + if (-not $resolvedTmp.StartsWith($tempRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or + -not ([IO.Path]::GetFileName($resolvedTmp) -match '^gitea-doctor-[0-9a-f]{32}$')) { + throw "unsafe diagnostic clone cleanup target: $resolvedTmp" + } + if (Test-Path -LiteralPath $resolvedTmp) { + Remove-Item -LiteralPath $resolvedTmp -Recurse -Force -ErrorAction SilentlyContinue + } if ($code -eq 0) { $result.Pack = $true } else { $result.Detail = ($out -join " ") } return $result } @@ -123,6 +131,57 @@ function Invoke-Remote([string]$HostName, [string]$Port, [string]$LocalScript, [ return @{ Code = $code; Out = ($out -join "`n") } } +function Find-PuttyExe([string]$Name) { + $found = Get-Command $Name -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + $candidate = Join-Path $env:ProgramFiles ("PuTTY\" + $Name) + if (Test-Path -LiteralPath $candidate) { return $candidate } + return "" +} + +function Get-BmemGiteaPassword { + # A previously user-authorized machine/global record is a credential source, + # not a value to print or copy into any repository. Use it only when the + # key-based SSH path is unavailable on this machine. + $memoryFile = Join-Path $env:USERPROFILE "bmem.md" + if (-not (Test-Path -LiteralPath $memoryFile)) { return "" } + $memoryText = Get-Content -LiteralPath $memoryFile -Raw -Encoding UTF8 + $entry = [regex]::Match($memoryText, '(?m)^\s*-\s*\[G-0009\].*$').Value + if (-not $entry) { return "" } + $pattern = 'SSH\s*\u767B\u5F55\s*=\s*\u7528\u6237\u540D\s*bowei\s*[\u3001,\uFF0C]\s*\u5BC6\u7801\s*([^;\uFF1B,\uFF0C\u3001\u3002\s]+)' + return [regex]::Match($entry, $pattern).Groups[1].Value +} + +function Invoke-RemotePutty([string]$HostName, [string]$Port, [string]$LocalScript, [string[]]$ScriptArgs) { + $fingerprint = Get-EnvKey "GITEA_HOST_KEY_FP" + $password = Get-BmemGiteaPassword + $plink = Find-PuttyExe "plink.exe" + $pscp = Find-PuttyExe "pscp.exe" + if (-not $fingerprint -or -not $password -or -not $plink -or -not $pscp) { + return @{ Code = 255; Out = "PuTTY fallback lacks a pinned host fingerprint, previously authorized bmem credential, or PuTTY executables" } + } + + $remote = "/tmp/tools-sync-triage.$PID.putty.sh" + $prev = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $plinkArgs = @("-batch", "-ssh", "-hostkey", $fingerprint, "-P", $Port, + "-pw", $password, "$sshUser@$HostName") + $probe = & $plink @plinkArgs "test ! -e $remote" 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { + $ErrorActionPreference = $prev + return @{ Code = 255; Out = "remote triage staging path is occupied or SSH authentication failed: " + ($probe -join " ") } + } + $cp = & $pscp -batch -hostkey $fingerprint -P $Port -pw $password $LocalScript "${sshUser}@${HostName}:$remote" 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { + $ErrorActionPreference = $prev + return @{ Code = 255; Out = "PuTTY script transfer failed: " + ($cp -join " ") } + } + $cmd = "sh $remote " + ($ScriptArgs -join " ") + "; rc=`$?; rm -f $remote; exit `$rc" + $out = & $plink @plinkArgs $cmd 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = $prev + return @{ Code = $code; Out = ($out -join "`n") } +} + $triage = Join-Path $PSScriptRoot "gitea_triage.sh" if (-not (Test-Path $triage)) { Fail "gitea_triage.sh missing next to this script." } # Normalise to LF and no BOM before shipping it: a CRLF or BOM on the shebang line makes @@ -146,6 +205,16 @@ foreach ($tg in $targets) { Write-Host " unreachable: $(($r.Out -split "`n")[0])" $r = $null } +if (-not $r) { + Write-Host " OpenSSH authentication unavailable; trying the pinned PuTTY credential path" + foreach ($tg in $targets) { + Write-Host "=== server triage via pinned PuTTY: $sshUser@$($tg.H):$($tg.P) ===" + $r = Invoke-RemotePutty $tg.H $tg.P $triageStaged $triageArgs + if ($r.Code -ne 255) { break } + Write-Host " unavailable: $(($r.Out -split "`n")[0])" + $r = $null + } +} Remove-Item $triageStaged -Force -ErrorAction SilentlyContinue if (-not $r) { Fail "could not reach the gitea host on any configured path (see GITEA_SSH_HOST / _ALT)." } diff --git a/tools-sync/scripts/gitea_triage.sh b/tools-sync/scripts/gitea_triage.sh index 3d834d4..7f2f639 100644 --- a/tools-sync/scripts/gitea_triage.sh +++ b/tools-sync/scripts/gitea_triage.sh @@ -87,10 +87,19 @@ if [ "$HOOKCFG" != 0 ] || [ "$CFG_SIZE" -gt 65536 ] 2>/dev/null; then bad gitconfig "size=${CFG_SIZE}B suspicious-directives=${HOOKCFG} (expected a few hundred B, 0)" dex "grep -inE 'packObjectsHook|core\\.hooksPath|sshCommand' $GCFG | head -5" if [ "$FIX" = 1 ]; then - # gitea rewrites a clean global gitconfig at startup, so quarantine + restart is - # both the repair and the proof: hand-editing it would only paper over the entry. - dex "cp $GCFG /data/gitea/home/.gitconfig.quarantine.$TS 2>/dev/null || cp $GCFG $GCFG.quarantine.$TS" - dex "rm -f $GCFG" + # Preserve both the injected config and its referenced executable as + # reversible evidence before startup rebuilds the global config. + HOOK_SCRIPT=$(dex "grep -oE '/data/gitea/home/[A-Za-z0-9_.-]+\\.sh' $GCFG | head -1" | tr -d '\r') + if [ -n "$HOOK_SCRIPT" ]; then + case "$HOOK_SCRIPT" in + /data/gitea/home/*.sh) + if dex "[ -f $HOOK_SCRIPT ] && [ ! -e $HOOK_SCRIPT.quarantine.$TS ]" >/dev/null 2>&1; then + dex "mv $HOOK_SCRIPT $HOOK_SCRIPT.quarantine.$TS" + echo "INFO injected hook script quarantined: $HOOK_SCRIPT.quarantine.$TS" + fi ;; + esac + fi + dex "[ ! -e $GCFG.quarantine.$TS ] && mv $GCFG $GCFG.quarantine.$TS" $D restart "$CONTAINER" >/dev/null 2>&1 sleep 12 NEW=$(dex "wc -c < $GCFG 2>/dev/null" | tr -d '\r ') @@ -180,10 +189,11 @@ fi PUB=$(dex 'ls -d /data/git/repositories/*/*.git 2>/dev/null | head -5' | tr -d '\r') CLONE_OK=0 CLONE_MSG="no public repo reachable anonymously" +CLONE_DIR="/tmp/tools-sync-httpclone.$TS.$$" for r in $PUB; do owner=$(basename "$(dirname "$r")") name=$(basename "$r" .git) - out=$(dex "rm -rf /tmp/tsdoc; GIT_TERMINAL_PROMPT=0 git clone -q --filter=tree:0 --depth 1 http://localhost:3000/$owner/$name.git /tmp/tsdoc 2>&1; ls /tmp/tsdoc/.git >/dev/null 2>&1 && echo TSDOC_OK; rm -rf /tmp/tsdoc") + out=$(dex "test ! -e $CLONE_DIR || exit 3; GIT_TERMINAL_PROMPT=0 git clone -q --filter=tree:0 --depth 1 http://localhost:3000/$owner/$name.git $CLONE_DIR 2>&1; ls $CLONE_DIR/.git >/dev/null 2>&1 && echo TSDOC_OK; rm -rf $CLONE_DIR") case "$out" in *TSDOC_OK*) CLONE_OK=1; CLONE_MSG="$owner/$name"; break ;; *"could not read Username"*|*"Authentication"*) continue ;;