feat(ccw): Add count-based memory update strategy and fix MCP card click

- Add count-based update strategy for memory hooks (threshold-based triggering)
- Add threshold config field (default: 10, range: 3-50)
- Add i18n translations for count-based strategy (EN/ZH)
- Fix MCP card click not responding due to HTML entity encoding in JSON
- Add unescapeHtml() utility function for decoding HTML entities
- Move stopPropagation from container div to individual buttons
- Change CCW MCP install to use cmd /c npx -y format for Windows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-19 11:41:21 +08:00
parent 69049e3f45
commit c7ced2bfbb
6 changed files with 95 additions and 13 deletions

View File

@@ -75,6 +75,19 @@ const HOOK_TEMPLATES = {
interval: { type: 'number', default: 300, min: 60, max: 3600, label: 'Interval (seconds)', step: 60 }
}
},
'memory-update-count-based': {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'THRESHOLD=10; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
threshold: { type: 'number', default: 10, min: 3, max: 50, label: 'File count threshold', step: 1 }
}
},
// SKILL Context Loader templates
'skill-context-keyword': {
event: 'UserPromptSubmit',
@@ -165,11 +178,18 @@ const WIZARD_TEMPLATES = {
name: 'Periodic Update',
description: 'Update documentation at regular intervals during session',
templateId: 'memory-update-periodic'
},
{
id: 'count-based',
name: 'Count-Based Update',
description: 'Update documentation when file changes reach threshold',
templateId: 'memory-update-count-based'
}
],
configFields: [
{ key: 'tool', type: 'select', label: 'CLI Tool', options: ['gemini', 'qwen', 'codex'], default: 'gemini', description: 'Tool for documentation generation' },
{ key: 'interval', type: 'number', label: 'Interval (seconds)', default: 300, min: 60, max: 3600, step: 60, showFor: ['periodic'], description: 'Time between updates' },
{ key: 'threshold', type: 'number', label: 'File Count Threshold', default: 10, min: 3, max: 50, step: 1, showFor: ['count-based'], description: 'Number of file changes to trigger update' },
{ key: 'strategy', type: 'select', label: 'Update Strategy', options: ['related', 'single-layer'], default: 'related', description: 'Related: changed modules, Single-layer: current directory' }
]
},
@@ -621,6 +641,7 @@ function renderWizardModalContent() {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdate');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
@@ -638,6 +659,7 @@ function renderWizardModalContent() {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdateDesc');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
@@ -656,6 +678,7 @@ function renderWizardModalContent() {
const labels = {
'tool': t('hook.wizard.cliTool'),
'interval': t('hook.wizard.intervalSeconds'),
'threshold': t('hook.wizard.fileCountThreshold'),
'strategy': t('hook.wizard.updateStrategy')
};
return labels[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.label || fieldKey;
@@ -665,6 +688,7 @@ function renderWizardModalContent() {
const descs = {
'tool': t('hook.wizard.toolForDocGen'),
'interval': t('hook.wizard.timeBetweenUpdates'),
'threshold': t('hook.wizard.fileCountThresholdDesc'),
'strategy': t('hook.wizard.relatedStrategy')
};
return descs[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.description || '';
@@ -999,12 +1023,15 @@ function generateWizardCommand() {
const tool = wizardConfig.tool || 'gemini';
const strategy = wizardConfig.strategy || 'related';
const interval = wizardConfig.interval || 300;
const threshold = wizardConfig.threshold || 10;
// Build the ccw tool command based on configuration
const params = JSON.stringify({ strategy, tool });
if (triggerType === 'periodic') {
return `INTERVAL=${interval}; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
} else if (triggerType === 'count-based') {
return `THRESHOLD=${threshold}; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
} else {
return `ccw tool exec update_module_claude '${params}'`;
}

View File

@@ -930,8 +930,8 @@ function selectCcwTools(type) {
// Build CCW Tools config with selected tools
function buildCcwToolsConfig(selectedTools) {
const config = {
command: "npx",
args: ["-y", "ccw-mcp"]
command: "cmd",
args: ["/c", "npx", "-y", "ccw-mcp"]
};
// Add env if not all tools or not default 4 core tools

View File

@@ -728,6 +728,10 @@ const i18n = {
'hook.wizard.onSessionEndDesc': 'Update documentation when Claude session ends',
'hook.wizard.periodicUpdate': 'Periodic Update',
'hook.wizard.periodicUpdateDesc': 'Update documentation at regular intervals during session',
'hook.wizard.countBasedUpdate': 'Count-Based Update',
'hook.wizard.countBasedUpdateDesc': 'Update documentation when file changes reach threshold',
'hook.wizard.fileCountThreshold': 'File Count Threshold',
'hook.wizard.fileCountThresholdDesc': 'Number of file changes to trigger update',
'hook.wizard.skillContext': 'SKILL Context Loader',
'hook.wizard.skillContextDesc': 'Automatically load SKILL packages based on keywords in user prompts',
'hook.wizard.keywordMatching': 'Keyword Matching',
@@ -1977,6 +1981,10 @@ const i18n = {
'hook.wizard.onSessionEndDesc': 'Claude 会话结束时更新文档',
'hook.wizard.periodicUpdate': '定期更新',
'hook.wizard.periodicUpdateDesc': '会话期间定期更新文档',
'hook.wizard.countBasedUpdate': '累计数量更新',
'hook.wizard.countBasedUpdateDesc': '文件变动达到阈值时更新文档',
'hook.wizard.fileCountThreshold': '文件数量阈值',
'hook.wizard.fileCountThresholdDesc': '触发更新的文件变动数量',
'hook.wizard.skillContext': 'SKILL 上下文加载器',
'hook.wizard.skillContextDesc': '根据用户提示中的关键词自动加载 SKILL 包',
'hook.wizard.keywordMatching': '关键词匹配',

View File

@@ -20,6 +20,21 @@ function escapeHtml(str) {
.replace(/'/g, '&#039;');
}
/**
* Unescape HTML entities back to original characters
* @param {string} str - String with HTML entities
* @returns {string} String with entities decoded
*/
function unescapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
/**
* Truncate text to specified maximum length
* @param {string} text - Text to truncate

View File

@@ -221,6 +221,7 @@ function renderWizardCard(wizardId) {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdate');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
@@ -238,6 +239,7 @@ function renderWizardCard(wizardId) {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdateDesc');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');

View File

@@ -863,12 +863,13 @@ function renderProjectAvailableServerCard(entry) {
` : ''}
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between gap-2" onclick="event.stopPropagation()">
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
data-action="save-as-template"
onclick="event.stopPropagation()"
title="${t('mcp.saveAsTemplate')}">
<i data-lucide="save" class="w-3 h-3"></i>
${t('mcp.saveAsTemplate')}
@@ -877,7 +878,8 @@ function renderProjectAvailableServerCard(entry) {
${canRemove ? `
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(name)}"
data-action="remove">
data-action="remove"
onclick="event.stopPropagation()">
${t('mcp.removeFromProject')}
</button>
` : ''}
@@ -929,10 +931,11 @@ function renderGlobalManagementCard(serverName, serverConfig) {
</div>
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end" onclick="event.stopPropagation()">
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end">
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove-global">
data-action="remove-global"
onclick="event.stopPropagation()">
${t('mcp.removeGlobal')}
</button>
</div>
@@ -1337,6 +1340,11 @@ function openCodexMcpCreateModal() {
}
function attachMcpEventListeners() {
// Debug: Log event listener attachment
const viewDetailsCards = document.querySelectorAll('.mcp-server-card[data-action="view-details"]');
const codexCards = document.querySelectorAll('.mcp-server-card[data-action="view-details-codex"]');
console.log('[MCP] Attaching event listeners - Claude cards:', viewDetailsCards.length, 'Codex cards:', codexCards.length);
// Toggle switches
document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => {
input.addEventListener('change', async (e) => {
@@ -1553,19 +1561,41 @@ function attachMcpEventListeners() {
// View details / Edit - click on Claude server card
document.querySelectorAll('.mcp-server-card[data-action="view-details"]').forEach(card => {
card.addEventListener('click', (e) => {
const serverName = card.dataset.serverName;
const serverConfig = JSON.parse(card.dataset.serverConfig);
const serverSource = card.dataset.serverSource;
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
// Don't trigger if clicking on buttons or toggle
if (e.target.closest('button') || e.target.closest('label') || e.target.closest('input')) {
return;
}
try {
const serverName = card.dataset.serverName;
// Decode HTML entities before parsing JSON
const configStr = unescapeHtml(card.dataset.serverConfig);
const serverConfig = JSON.parse(configStr);
const serverSource = card.dataset.serverSource;
console.log('[MCP] Card clicked:', serverName, serverSource);
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
} catch (err) {
console.error('[MCP] Error handling card click:', err, card.dataset.serverConfig);
}
});
});
// View details / Edit - click on Codex server card
document.querySelectorAll('.mcp-server-card[data-action="view-details-codex"]').forEach(card => {
card.addEventListener('click', (e) => {
const serverName = card.dataset.serverName;
const serverConfig = JSON.parse(card.dataset.serverConfig);
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
// Don't trigger if clicking on buttons or toggle
if (e.target.closest('button') || e.target.closest('label') || e.target.closest('input')) {
return;
}
try {
const serverName = card.dataset.serverName;
// Decode HTML entities before parsing JSON
const configStr = unescapeHtml(card.dataset.serverConfig);
const serverConfig = JSON.parse(configStr);
console.log('[MCP] Codex card clicked:', serverName);
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
} catch (err) {
console.error('[MCP] Error handling Codex card click:', err, card.dataset.serverConfig);
}
});
});