Skip to content
Development
Hook

Hooks

What claude-memory-engine runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
claude-memory-engine
13418 commands9 hooks

Where it lives

  • hooks/memory-backup.shGitHub
    Read the script
    #!/bin/bash
    # ============================================================
    # Claude Environment Engine — Cross-machine sync
    # Claude 環境引擎 — 跨機器同步
    # ============================================================
    # Despite the name "memory-backup", this script mirrors the WHOLE
    # ~/.claude/ environment (CLAUDE.md, hooks, slash commands, skills,
    # settings.json, per-project memory) between machines via a GitHub repo.
    #
    # Usage:
    #   bash memory-backup.sh [push|pull|sync|status] [--dry-run]
    #
    #   (no args)   = SessionEnd auto mode (commit only, no push, no pull)
    #   push        = local → GitHub (commit + push)
    #   pull        = GitHub → local (pulls CLAUDE.md and engine files)
    #   sync        = pull + push (full bidirectional sync)
    #   status      = list differences between local / repo, no changes
    #   --dry-run   = print what WOULD be copied, no actual changes
    #
    # Design principle:
    #   SYNC_TABLE is the single source of truth — both push and pull
    #   read this table, preventing the "push syncs X but pull forgets X"
    #   asymmetry bug that previously caused secondary machines to miss
    #   updates to CLAUDE.md, commands, hooks, etc.
    # ============================================================
    
    set -e
    
    HOME_DIR="${HOME:-/c/Users/kaoru}"
    CLAUDE_DIR="$HOME_DIR/.claude"
    CLAUDE_MD="$CLAUDE_DIR/CLAUDE.md"
    REPO_DIR="$CLAUDE_DIR/claude-memory"
    MACHINE_ID_FILE="$CLAUDE_DIR/machine-id"
    PAUSE_LOCK="$CLAUDE_DIR/.bb-pause"
    
    # 旗標解析
    DRY_RUN=0
    for arg in "$@"; do
      if [ "$arg" = "--dry-run" ]; then
        DRY_RUN=1
      fi
    done
    
    # 暫停鎖(避免半成品腳本被 SessionEnd 自動 commit)
    if [ -f "$PAUSE_LOCK" ] && [ -z "$1" ]; then
      echo "[BB] Pause lock detected ($PAUSE_LOCK), skipping auto-commit / 偵測到暫停鎖,跳過自動 commit"
      exit 0
    fi
    
    # 確認 repo 存在
    if [ ! -d "$REPO_DIR/.git" ]; then
      echo "[BB] claude-memory repo not found: $REPO_DIR"
      echo "[BB] 找不到備份 repo。Create one with: gh repo create claude-memory --private"
      exit 1
    fi
    
    # ============================================================
    # 機器身分辨識
    # ============================================================
    detect_machine() {
      if [ -f "$MACHINE_ID_FILE" ]; then
        cat "$MACHINE_ID_FILE"
        return
      fi
      # Fallback:使用 hostname 當機器代號
      # 你也可以建 ~/.claude/machine-id 寫死自訂名稱(例如 desktop / laptop / work-mac)
      hostname 2>/dev/null || echo "unknown"
    }
    
    MACHINE=$(detect_machine)
    
    # ============================================================
    # 同步清單 (Single Source of Truth)
    # ============================================================
    # 格式:本地路徑|repo 路徑|類型|說明
    #   類型:file=單檔  dir=整個資料夾遞迴  dir-md=只同步 .md  dir-skill=skill 多檔
    # 順序:CLAUDE.md 第一,最高指示優先處理
    SYNC_TABLE=(
      "$CLAUDE_DIR/CLAUDE.md|$REPO_DIR/CLAUDE.md|file|CLAUDE.md (top-level rules)"
      "$CLAUDE_DIR/settings.json|$REPO_DIR/settings.json|file-settings|settings.json (backs up before overwrite)"
      "$CLAUDE_DIR/commands|$REPO_DIR/commands|dir-md|slash commands"
      "$CLAUDE_DIR/scripts/hooks|$REPO_DIR/hooks|dir|hook engine scripts"
      "$CLAUDE_DIR/skills/learned|$REPO_DIR/skills-learned|dir-skill|learned skills (auto-saved pitfalls)"
      "$CLAUDE_DIR/skills|$REPO_DIR/skills|dir-skill-core|core skills"
    )
    
    # ============================================================
    # 共用工具
    # ============================================================
    # 比較兩個檔案是否需要同步(目標不存在或來源較新)
    needs_sync() {
      local src="$1"
      local dst="$2"
      [ ! -f "$dst" ] || [ "$src" -nt "$dst" ]
    }
    
    # 印「會動什麼」(dry-run 用,走 stderr 避免汙染回傳值)
    plan_msg() {
      if [ $DRY_RUN -eq 1 ]; then
        echo "  [DRY-RUN] 會複製: $1" >&2
      fi
    }
    
    # 實際複製(dry-run 跳過)
    do_copy() {
      local src="$1"
      local dst="$2"
      if [ $DRY_RUN -eq 1 ]; then
        plan_msg "$src -> $dst"
      else
        cp "$src" "$dst"
      fi
    }
    
    # ============================================================
    # 通用同步引擎 — push/pull 都呼叫這個
    # ============================================================
    # 參數:方向(push|pull)
    sync_engine() {
      local direction="$1"
      local total=0
    
      for entry in "${SYNC_TABLE[@]}"; do
        IFS='|' read -r local_path repo_path type desc <<< "$entry"
    
        local src dst
        if [ "$direction" = "push" ]; then
          src="$local_path"; dst="$repo_path"
        else
          src="$repo_path"; dst="$local_path"
        fi
    
        # 跳過 .bak.* 備份檔(不該上 GitHub)
        case "$local_path" in *.bak.*) continue ;; esac
    
        case "$type" in
          file)
            if [ -f "$src" ] && needs_sync "$src" "$dst"; then
              do_copy "$src" "$dst"
              total=$((total + 1))
            fi
            ;;
    
          file-settings)
            # settings.json 特殊處理:覆蓋前備份
            if [ -f "$src" ] && needs_sync "$src" "$dst"; then
              if [ -f "$dst" ]; then
                local bak="$dst.bak.$(date +%Y%m%d)"
                if [ ! -f "$bak" ] && [ $DRY_RUN -eq 0 ]; then
                  cp "$dst" "$bak"
                  echo "[BB] Backed up old $(basename $dst) to $bak"
                fi
              fi
              do_copy "$src" "$dst"
              total=$((total + 1))
            fi
            ;;
    
          dir-md)
            # 只同步 .md 檔
            [ -d "$src" ] || continue
            [ $DRY_RUN -eq 0 ] && mkdir -p "$dst"
            for f in "$src"/*.md; do
              [ -f "$f" ] || continue
              local target="$dst/$(basename $f)"
              if needs_sync "$f" "$target"; then
                do_copy "$f" "$target"
                total=$((total + 1))
              fi
            done
            ;;
    
          dir)
            # 整個資料夾遞迴(含所有副檔名,跳過 .bak.*)
            [ -d "$src" ] || continue
            [ $DRY_RUN -eq 0 ] && mkdir -p "$dst"
            for f in "$src"/*; do
              [ -f "$f" ] || continue
              case "$(basename $f)" in *.bak.*) continue ;; esac
              local target="$dst/$(basename $f)"
              if needs_sync "$f" "$target"; then
                do_copy "$f" "$target"
                total=$((total + 1))
              fi
            done
            # .sh 保留執行權限(pull 後本機要能跑)
            if [ "$direction" = "pull" ] && [ $DRY_RUN -eq 0 ]; then
              chmod +x "$dst"/*.sh 2>/dev/null || true
            fi
            ;;
    
          dir-skill)
            # 多檔 Skill:根目錄單檔 + 各子資料夾(含 references/)
            [ -d "$src" ] || continue
            [ $DRY_RUN -eq 0 ] && mkdir -p "$dst"
            # 根目錄各種副檔
  • hooks/memory-sync.jsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * UserPromptSubmit Hook — 跨 Session 記憶同步 + 變更摘要注入
     * 偵測 MEMORY.md 被其他 session 更新時,注入變更內容給 Claude
     */
    
    const fs = require('fs');
    const path = require('path');
    
    const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
    
    const MEMORY_DIR = HOME_DIR ? path.join(HOME_DIR, '.claude', 'projects') : '';
    
    const STATE_FILE = HOME_DIR ? path.join(HOME_DIR, '.claude', 'scripts', 'hooks', '.memory-sync-state.json') : '';
    
    function getProjectMemoryDir() {
      const parts = process.cwd().replace(/\\/g, '/').split('/').filter(Boolean);
      if (parts.length === 0) return null;
    
      const drive = parts[0].replace(':', '');
      const rest = parts.slice(1).join('-');
      const projectId = `${drive}--${rest}`;
    
      const memDir = path.join(MEMORY_DIR, projectId, 'memory');
      if (fs.existsSync(memDir)) return memDir;
    
      // fallback:嘗試家目錄的 memory
      const homeId = `${drive}--Users-${parts[1] || 'user'}`;
      const homeMemDir = path.join(MEMORY_DIR, homeId, 'memory');
      if (fs.existsSync(homeMemDir)) return homeMemDir;
    
      return null;
    }
    
    function loadState() {
      try {
        if (fs.existsSync(STATE_FILE)) {
          return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
        }
      } catch (e) {}
      return {};
    }
    
    function saveState(state) {
      try {
        fs.writeFileSync(STATE_FILE, JSON.stringify(state), 'utf-8');
      } catch (e) {}
    }
    
    // 簡單的 diff:找出新增的行
    function getChangedLines(oldContent, newContent) {
      const oldLines = new Set(oldContent.split('\n').map(l => l.trim()).filter(Boolean));
      const newLines = newContent.split('\n').map(l => l.trim()).filter(Boolean);
      return newLines.filter(l => !oldLines.has(l));
    }
    
    // === 交接偵測:偵測新的 handoff 檔案 / Detect new handoff files ===
    function checkHandoffs(memDir, state) {
      const handoffFiles = fs.readdirSync(memDir)
        .filter(f => f.startsWith('handoff-') && f.endsWith('.md'));
    
      const knownHandoffs = new Set(state['known_handoffs'] || []);
      const newHandoffs = handoffFiles.filter(f => !knownHandoffs.has(f));
    
      if (newHandoffs.length > 0) {
        // 更新已知清單 / Update known list
        state['known_handoffs'] = [...new Set([...knownHandoffs, ...newHandoffs])];
    
        const output = [];
        output.push(`[Handoff] 收到新交接 ${newHandoffs.length} 份:`);
        for (const f of newHandoffs) {
          const fp = path.join(memDir, f);
          const content = fs.readFileSync(fp, 'utf-8').trim();
          const body = content.replace(/^---[\s\S]*?---\s*/m, '').trim();
          const preview = body.split('\n').slice(0, 10).join('\n');
          output.push(`--- ${f} ---\n${preview}`);
        }
        return output.join('\n');
      }
      return null;
    }
    
    function main() {
      try {
        // null guard:HOME 拿不到就退出,避免路徑拼接出錯
        if (!HOME_DIR) return;
    
        const memDir = getProjectMemoryDir();
        if (!memDir) return;
    
        const memoryFile = path.join(memDir, 'MEMORY.md');
        if (!fs.existsSync(memoryFile)) return;
    
        const stat = fs.statSync(memoryFile);
        const currentMtime = stat.mtimeMs;
        const currentContent = fs.readFileSync(memoryFile, 'utf-8');
    
        const state = loadState();
        const lastMtime = state[memoryFile + ':mtime'] || 0;
        const lastHash = state[memoryFile + ':hash'] || '';
    
        // 用內容 hash 判斷(比 mtime 更準)
        const currentHash = Buffer.from(currentContent).toString('base64').substring(0, 32);
    
        if (lastMtime === 0 || lastHash === '') {
          // 第一次執行,記錄狀態
          state[memoryFile + ':mtime'] = currentMtime;
          state[memoryFile + ':hash'] = currentHash;
          state[memoryFile + ':content'] = currentContent;
          saveState(state);
          return;
        }
    
        if (currentHash !== lastHash) {
          // 記憶有變更!找出改了什麼
          const oldContent = state[memoryFile + ':content'] || '';
          const changedLines = getChangedLines(oldContent, currentContent);
    
          // 同時檢查其他 md 檔案
          const changedFiles = [];
          const mdFiles = fs.readdirSync(memDir).filter(f => f.endsWith('.md'));
          for (const f of mdFiles) {
            const fp = path.join(memDir, f);
            const fstat = fs.statSync(fp);
            const fLastMtime = state[fp + ':mtime'] || 0;
            if (fstat.mtimeMs > fLastMtime) {
              changedFiles.push(f);
              state[fp + ':mtime'] = fstat.mtimeMs;
            }
          }
    
          // 更新狀態
          state[memoryFile + ':mtime'] = currentMtime;
          state[memoryFile + ':hash'] = currentHash;
          state[memoryFile + ':content'] = currentContent;
          saveState(state);
    
          // 注入變更摘要
          const output = [];
          if (changedFiles.length > 0) {
            output.push(`[Memory Sync] 記憶檔案被更新了:${changedFiles.join(', ')}`);
          }
          if (changedLines.length > 0) {
            const preview = changedLines.slice(0, 5).join('\n  ');
            output.push(`[Memory Sync] 新增/修改的內容:\n  ${preview}`);
            if (changedLines.length > 5) {
              output.push(`  ...還有 ${changedLines.length - 5} 行`);
            }
          }
    
          if (output.length > 0) {
            process.stdout.write(output.join('\n') + '\n');
          }
        }
    
        // 交接偵測 / Handoff detection
        const handoffMsg = checkHandoffs(memDir, state);
        if (handoffMsg) {
          saveState(state);
          process.stdout.write(handoffMsg + '\n');
        }
      } catch (err) {
        // 靜默失敗
      }
    }
    
    main();
    
  • hooks/mid-session-checkpoint.jsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * UserPromptSubmit Hook — Mid-session Checkpoint / 中繼摘要計數器
     * Saves a checkpoint every N user messages to sessions/
     * 每 N 次使用者訊息,自動存一份中繼摘要到 sessions/
     */
    
    const fs = require('fs');
    const path = require('path');
    
    const HOME = process.env.HOME || process.env.USERPROFILE;
    const SESSIONS_DIR = path.join(HOME, '.claude', 'sessions');
    const STATE_FILE = path.join(SESSIONS_DIR, '.checkpoint-state.json');
    const CHECKPOINT_INTERVAL = 20; // Save every 20 messages / 每 20 則訊息存一次
    
    function ensureDir(dir) {
      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    }
    
    function loadState() {
      try {
        if (fs.existsSync(STATE_FILE)) {
          return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
        }
      } catch (e) {}
      return {};
    }
    
    function saveState(state) {
      try {
        ensureDir(SESSIONS_DIR);
        fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
      } catch (e) {}
    }
    
    /**
     * Mini analysis: extract key actions and project names from messages
     * 輕量 mini 分析:從訊息中抓出關鍵動作和常見檔案/專案名
     * Simple string matching only — no regex backtracking, keeps the hook fast
     * 只用簡單字串比對,不做正規式回溯,避免拖慢 hook
     */
    function miniAnalyze(messages) {
      // Action keyword map (keyword → display label) / 動作詞對照表
      // Add your own keywords here / 在這裡加入你的關鍵字
      const ACTION_MAP = {
        '部署': '部署', '推推': '部署', '上線': '部署', 'push': '部署', 'deploy': '部署',
        '修': '修改', '改': '修改', 'fix': '修改', 'bug': '除錯',
        '寫': '撰寫', '建': '建立', '新增': '新增', 'create': '建立',
        '測試': '測試', 'test': '測試', 'debug': '除錯',
        '刪': '刪除', '移除': '移除', 'delete': '刪除',
        '設定': '設定', 'config': '設定', '安裝': '安裝', 'install': '安裝',
        '讀取': '讀取', 'RR': '讀取', 'MM': '記憶', '備份': '備份', 'BB': '備份',
        '截圖': '驗證', '確認': '驗證',
      };
    
      // Project/file keywords — add your own project names here
      // 常見專案/檔案名稱 — 在這裡加入你的專案名
      const PROJECT_KEYWORDS = [
        // 'my-project', 'my-app',  // <-- add yours / 加入你的
        'astro', 'MEMORY', 'CLAUDE.md',
      ];
    
      const allText = messages.join(' ').toLowerCase();
    
      // Count action keyword occurrences / 統計動作詞出現次數
      const actionCounts = {};
      for (const [keyword, label] of Object.entries(ACTION_MAP)) {
        if (allText.includes(keyword.toLowerCase())) {
          actionCounts[label] = (actionCounts[label] || 0) + 1;
        }
      }
    
      // Sort by frequency, take top 3 / 依出現次數排序,取前 3 個動作
      const topActions = Object.entries(actionCounts)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 3)
        .map(([label]) => label);
    
      // Count project/file name occurrences / 統計專案/檔案名出現次數
      const projectCounts = {};
      for (const name of PROJECT_KEYWORDS) {
        const lowerName = name.toLowerCase();
        let count = 0;
        let idx = 0;
        while ((idx = allText.indexOf(lowerName, idx)) !== -1) {
          count++;
          idx += lowerName.length;
        }
        if (count > 0) projectCounts[name] = count;
      }
    
      // Top 2 most mentioned projects / 取最常提到的專案(前 2 個)
      const topProjects = Object.entries(projectCounts)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 2)
        .map(([name]) => name);
    
      // Build one-line summary / 組一句話摘要
      const actionPart = topActions.length > 0 ? topActions.join(', ') : 'misc';
      const projectPart = topProjects.length > 0 ? `(${topProjects.join('、')})` : '';
      const summary = `${actionPart}${projectPart}`;
    
      return { topActions, topProjects, summary };
    }
    
    function saveCheckpoint(sessionId, messages) {
      ensureDir(SESSIONS_DIR);
    
      const now = new Date();
      const dateStr = now.toISOString().split('T')[0];
      const timeStr = now.toTimeString().split(' ')[0].substring(0, 5);
      const shortId = sessionId ? sessionId.substring(0, 8) : Math.random().toString(36).substring(2, 6);
      const filename = `${dateStr}-${shortId}-checkpoint.md`;
    
      // Extract title from messages / 從訊息中擷取標題
      const titleHint = messages.slice(0, 3).join(' ').replace(/\n/g, ' ').substring(0, 50);
    
      // Recent messages (up to 10) / 取最近的訊息(最多 10 則)
      const recentMessages = messages.slice(-10);
    
      // Mini analysis: what was being done / mini 分析:這段期間在做什麼
      const analysis = miniAnalyze(messages);
    
      const content = `# Checkpoint: ${dateStr}
    **Title:** ${titleHint}
    **Time:** ${timeStr}
    **Messages:** ${messages.length}
    **Type:** mid-session checkpoint (auto, every ${CHECKPOINT_INTERVAL} messages)
    
    ## What was being done
    ${analysis.summary}
    
    ## User requests (recent ${recentMessages.length})
    ${recentMessages.map(m => `- ${m}`).join('\n')}
    `;
    
      fs.writeFileSync(path.join(SESSIONS_DIR, filename), content, 'utf-8');
    
      // Clean old checkpoints (keep latest 10) / 清理舊的 checkpoint
      try {
        const checkpoints = fs.readdirSync(SESSIONS_DIR)
          .filter(f => f.endsWith('-checkpoint.md'))
          .map(f => ({
            name: f,
            path: path.join(SESSIONS_DIR, f),
            mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs
          }))
          .sort((a, b) => b.mtime - a.mtime);
    
        if (checkpoints.length > 10) {
          for (const old of checkpoints.slice(10)) {
            try { fs.unlinkSync(old.path); } catch (e) {}
          }
        }
      } catch (e) {}
    }
    
    function main(inputData) {
      try {
        let data;
        try {
          data = JSON.parse(inputData);
        } catch (e) {
          return; // 解析失敗就靜默退出
        }
    
        const sessionId = data.session_id || 'unknown';
        const prompt = data.prompt || '';
    
        // Truncate to 200 chars / 截取前 200 字
        const shortPrompt = prompt.trim().substring(0, 200);
        if (!shortPrompt) return;
    
        const state = loadState();
    
        // Group by session_id / 用 session_id 分組
        if (!state[sessionId]) {
          state[sessionId] = {
            messages: [],
            lastCheckpoint: 0,
            startTime: new Date().toISOString()
          };
        }
    
        const session = state[sessionId];
        session.messages.push(shortPrompt);
        session.lastActivity = new Date().toISOString();
    
        // Check if it's time to save / 檢查是否到了存檔時機
        const messagesSinceCheckpoint = session.messages.length - session.lastCheckpoint;
        if (messagesSinceCheckpoint >= CHECKPOINT_INTERVAL) {
          saveCheckpoint(sessionId, session.messages);
          session.lastCheckpoint = session.messages.le
  • hooks/pre-compact.jsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * PreCompact Hook — 壓縮前快照(真正的安全網)
     * Pre-compact: snapshot before context compression (the real safety net)
     *
     * 比 SessionEnd 更常觸發(大部分對話不會正式結束)。
     * Fires more often than SessionEnd (most conversations don't formally end).
     *
     * 共用函式已抽到 shared-utils.js
     */
    
    const fs = require('fs');
    const path = require('path');
    const {
      SESSIONS_DIR, ensureDir, debugLog: _debugLog,
      parseTranscript, detectProjectTag, detectPitfalls, savePitfalls,
      updateProjectIndex, autoBackup,
    } = require('./shared-utils');
    
    const log = (msg) => _debugLog(msg, 'pre-compact');
    
    // === 主程式 / Main ===
    function main(inputData) {
      log('=== pre-compact started ===');
    
      try {
        let data;
        try {
          data = JSON.parse(inputData);
        } catch (parseErr) {
          log(`JSON parse failed: ${parseErr.message}`);
          return;
        }
    
        const trigger = data.trigger || 'unknown'; // "auto" or "manual"
        const transcriptPath = data.transcript_path;
        log(`trigger: ${trigger}, transcript: ${transcriptPath}`);
    
        const parsed = parseTranscript(transcriptPath, log);
        if (!parsed) {
          log('parseTranscript returned null');
          return;
        }
    
        if (parsed.userMessages.length === 0) {
          log('no user messages, skipping');
          return;
        }
    
        log(`messages: ${parsed.userMessages.length}, tools: ${parsed.toolsUsed.join(',')}, files: ${parsed.filesModified.join(',')}`);
    
        // === 存壓縮前快照 / Save compact snapshot ===
        ensureDir(SESSIONS_DIR);
    
        const now = new Date();
        const dateStr = now.toISOString().split('T')[0];
        const timeStr = now.toTimeString().split(' ')[0].substring(0, 5);
        const shortId = (data.session_id || '').substring(0, 8) || Math.random().toString(36).substring(2, 6);
        const filename = `${dateStr}-${shortId}-compact.md`;
    
        const projectTag = detectProjectTag(parsed.userMessages, data.cwd, parsed.filesModified);
        log(`project: ${projectTag}`);
    
        const meaningfulMessages = parsed.userMessages.filter(m => m.length > 3);
        const titleHint = meaningfulMessages.slice(0, 5).join(' ').replace(/\n/g, ' ').substring(0, 60);
        const recentMessages = parsed.userMessages.slice(-8);
    
        const triggerLabel = trigger === 'auto' ? 'auto (context full)' : 'manual (/compact)';
    
        const summary = `# Compact Snapshot: ${dateStr}
    **Project:** ${projectTag}
    **Title:** ${titleHint}
    **Time:** ${timeStr}
    **Messages:** ${parsed.userMessages.length}
    **Trigger:** ${triggerLabel}
    **Type:** pre-compact snapshot (conversation continues after this)
    
    ## User Requests
    ${recentMessages.map(m => `- ${m}`).join('\n')}
    
    ## Tools Used
    ${parsed.toolsUsed.join(', ') || 'none'}
    
    ## Files Modified
    ${parsed.filesModified.length > 0 ? parsed.filesModified.map(f => `- ${f}`).join('\n') : 'none'}
    `;
    
        fs.writeFileSync(path.join(SESSIONS_DIR, filename), summary, 'utf-8');
        log(`compact snapshot saved: ${filename}`);
    
        // === 更新專案索引 / Update project index ===
        updateProjectIndex(projectTag, dateStr, timeStr, titleHint, filename, 'compact');
    
        // === 踩坑偵測 / Pitfall detection ===
        const pitfalls = detectPitfalls(parsed);
        log(`pitfalls detected: ${pitfalls.length}`);
        if (pitfalls.length > 0) {
          savePitfalls(pitfalls);
        }
    
        // === 自動備份 / Auto backup ===
        autoBackup();
    
        log('=== pre-compact done ===');
      } catch (err) {
        log(`error: ${err.message}\n${err.stack}`);
      }
    }
    
    let input = '';
    process.stdin.on('data', chunk => { input += chunk; });
    process.stdin.on('end', () => main(input));
    
  • hooks/pre-push-check.jsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * PreToolUse Hook — Git Push 前檢查提醒
     * 匹配 Bash 工具,攔截 git push 相關指令
     * 同時檢查 staged files 有沒有敏感檔案
     */
    
    let input = '';
    process.stdin.on('data', chunk => { input += chunk; });
    process.stdin.on('end', () => {
      try {
        const data = JSON.parse(input);
        const command = data.tool_input?.command || '';
    
        if (/git\s+push/.test(command)) {
          process.stdout.write(
            '[Memory Engine] About to push to remote! Please verify:\n' +
            '  1. No files missing from git status\n' +
            '  2. Commit message is accurate\n' +
            '  3. No accidental sensitive files(.env、credentials)\n'
          );
    
          // 實際檢查 staged 檔案
          try {
            const { execSync } = require('child_process');
            const staged = execSync('git diff --cached --name-only 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
            const sensitivePatterns = [/\.env$/, /credentials/i, /\.secret/i, /password/i, /\.pem$/, /\.key$/];
            const dangerousFiles = staged.split('\n').filter(f =>
              f.trim() && sensitivePatterns.some(p => p.test(f))
            );
            if (dangerousFiles.length > 0) {
              process.stdout.write('[Memory Engine WARNING] Sensitive files found in staging: ' + dangerousFiles.join(', ') + '\n');
            }
          } catch (e) {}
        }
    
        if (/git\s+push\s+.*--force/.test(command) || /git\s+push\s+-f\b/.test(command)) {
          process.stdout.write('[Memory Engine WARNING] Force push detected! This will overwrite remote history. Be careful!\n');
        }
    
        process.exit(0);
      } catch (e) {
        process.exit(0);
      }
    });
    
  • hooks/session-end.jsGitHub
    Read the script
    #!/usr/bin/env node
    /**
     * SessionEnd Hook — session 結束自動存檔
     * Session end: auto-save summary
     *
     * 踩坑偵測已移到 pre-compact.js(壓縮前學習,時機更早更完整)
     * Pitfall detection moved to pre-compact.js (runs before compression, catches more context)
     *
     * 共用函式已抽到 shared-utils.js / Shared functions in shared-utils.js
     */
    
    const fs = require('fs');
    const path = require('path');
    const {
      SESSIONS_DIR, ensureDir, debugLog: _debugLog,
      parseTranscript, detectProjectTag,
      updateProjectIndex, autoBackup,
    } = require('./shared-utils');
    
    const MAX_SESSIONS = 30;
    const log = (msg) => _debugLog(msg, 'session-end');
    
    // === 清理舊 session / Clean old sessions ===
    function cleanOldSessions() {
      ensureDir(SESSIONS_DIR);
      const files = fs.readdirSync(SESSIONS_DIR)
        .filter(f => f.endsWith('-session.md'))
        .map(f => ({
          name: f,
          path: path.join(SESSIONS_DIR, f),
          mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs
        }))
        .sort((a, b) => b.mtime - a.mtime);
    
      if (files.length > MAX_SESSIONS) {
        for (const old of files.slice(MAX_SESSIONS)) {
          try { fs.unlinkSync(old.path); } catch (e) {}
        }
      }
    }
    
    // === 主程式 / Main ===
    function main(inputData) {
      log('=== session-end started ===');
    
      try {
        let data;
        try {
          data = JSON.parse(inputData);
        } catch (parseErr) {
          log(`JSON parse failed: ${parseErr.message}`);
          return;
        }
    
        const transcriptPath = data.transcript_path;
        log(`transcript_path: ${transcriptPath}`);
    
        const parsed = parseTranscript(transcriptPath, log);
        if (!parsed) {
          log('parseTranscript returned null');
          return;
        }
    
        if (parsed.userMessages.length === 0) {
          log('no user messages, skipping');
          return;
        }
    
        log(`messages: ${parsed.userMessages.length}, tools: ${parsed.toolsUsed.join(',')}, files: ${parsed.filesModified.join(',')}`);
    
        // 存 session 摘要 / Save session summary
        ensureDir(SESSIONS_DIR);
        cleanOldSessions();
    
        const now = new Date();
        const dateStr = now.toISOString().split('T')[0];
        const timeStr = now.toTimeString().split(' ')[0].substring(0, 5);
        const shortId = Math.random().toString(36).substring(2, 6);
        const filename = `${dateStr}-${shortId}-session.md`;
    
        const recentMessages = parsed.userMessages.slice(-8);
        const projectTag = detectProjectTag(parsed.userMessages, data.cwd, parsed.filesModified);
        log(`project: ${projectTag}`);
    
        const meaningfulMessages = parsed.userMessages.filter(m => m.length > 3 && !/^(可以|好|ok|是|對|要|嗯)$/i.test(m.trim()));
        const allTopics = meaningfulMessages.slice(0, 5).join(' ').substring(0, 100);
        const titleHint = allTopics.replace(/\n/g, ' ').substring(0, 60);
    
        const summary = `# Session: ${dateStr}
    **Project:** ${projectTag}
    **Title:** ${titleHint}
    **Time:** ${timeStr}
    **Messages:** ${parsed.userMessages.length}
    
    ## User Requests
    ${recentMessages.map(m => `- ${m}`).join('\n')}
    
    ## Tools Used
    ${parsed.toolsUsed.join(', ') || 'none'}
    
    ## Files Modified
    ${parsed.filesModified.length > 0 ? parsed.filesModified.map(f => `- ${f}`).join('\n') : 'none'}
    `;
    
        fs.writeFileSync(path.join(SESSIONS_DIR, filename), summary, 'utf-8');
        log(`session summary saved: ${filename}`);
    
        // 更新專案索引 / Update project index
        updateProjectIndex(projectTag, dateStr, timeStr, titleHint, filename);
    
        // 踩坑偵測已移到 pre-compact.js / Pitfall detection moved to pre-compact.js
    
        // 自動備份 / Auto backup
        autoBackup();
    
        log('=== session-end done ===');
      } catch (err) {
        log(`error: ${err.message}\n${err.stack}`);
      }
    }
    
    let input = '';
    process.stdin.on('data', chunk => { input += chunk; });
    process.stdin.on('end', () => main(input));
    
  • hooks/session-start.jsGitHub
  • hooks/shared-utils.jsGitHub
  • hooks/write-guard.jsGitHub

All 9 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withclaude-memory-engine

Claude Code 的記憶系統 | A memory system built with hooks + markdown. Zero dependencies.

Get the whole plugin
Stats
134
Stars
27
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
4mo ago
Last commit
6mo ago
Created

Repo: HelloRuru/claude-memory-engine