Alfred Agent v2.0 — production-grade runtime with 14 service modules
Core rewrite: - agent.js: streaming, mid-loop compaction, context tracking, skill matching - providers.js: streamQuery() method with SSE events for Anthropic - index.js: v2 HTTP server with 12 endpoints (/health, /chat/stream, /tokens, etc.) - cli.js: --stream flag, /tokens, /context, /skills commands New services: - tokenEstimation.js: multi-strategy token counting with context warnings - messages.js: typed message system (user/assistant/system/compact/tombstone) - compact.js: 4-tier compaction engine (micro → auto → memory → cleanup) - contextTracker.js: file/git/error/discovery tracking with scoring - steering.js: per-tool safety rules (OWASP-aligned) - skillEngine.js: SKILL.md parser with keyword triggers and hot reload - agentFork.js: sub-agent spawning with persistent task tracking - redact.js: Aho-Corasick secret scrubbing from tool outputs - intent.js, memory.js, permissions.js, costTracker.js, modelRouter.js, doctor.js
This commit is contained in:
parent
e89ce12316
commit
b7785c106b
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
||||
data/
|
||||
.env
|
||||
*.key
|
||||
*.bak
|
||||
|
||||
212
consolidate-workspace.sh
Executable file
212
consolidate-workspace.sh
Executable file
@ -0,0 +1,212 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ALFRED WORKSPACE CONSOLIDATOR
|
||||
#
|
||||
# Gathers ALL session data, memories, journals, plans, playbooks,
|
||||
# and archives from across every program into one unified directory.
|
||||
#
|
||||
# Sources consolidated:
|
||||
# 1. Copilot Chat memories (13 .md files)
|
||||
# 2. Cursor plans (24 .plan.md files)
|
||||
# 3. GoCodeMe chat sessions, playbooks, analytics
|
||||
# 4. Alfred Agent sessions (27+), transcripts, hook logs
|
||||
# 5. VS Code history (2910 dirs)
|
||||
# 6. Strategy docs (LAUNCH, PLAN, ROADMAP, TRIAGE, etc.)
|
||||
# 7. Backup archive (95 files — todos, agents, legal, etc.)
|
||||
# 8. VS Code workspace storage (14 workspaces)
|
||||
#
|
||||
# Output: ~/alfred-workspace-unified/
|
||||
#
|
||||
# Built by Commander Danny William Perez and Alfred.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
set -e
|
||||
|
||||
UNIFIED="$HOME/alfred-workspace-unified"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " ALFRED WORKSPACE CONSOLIDATOR"
|
||||
echo " Target: $UNIFIED"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
|
||||
# Create structure
|
||||
mkdir -p "$UNIFIED"/{memories,sessions,plans,playbooks,journals,strategy,archives,analytics,skills,hooks,history-index}
|
||||
|
||||
# ── 1. Copilot Chat Memories ──────────────────────────────────────
|
||||
SRC="$HOME/.vscode-server/data/User/globalStorage/github.copilot-chat/memory-tool/memories"
|
||||
if [ -d "$SRC" ]; then
|
||||
echo "✓ Copilot memories: $(ls "$SRC"/*.md 2>/dev/null | wc -l) files"
|
||||
cp -u "$SRC"/*.md "$UNIFIED/memories/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 2. Cursor Plans ───────────────────────────────────────────────
|
||||
SRC="$HOME/.cursor/plans"
|
||||
if [ -d "$SRC" ]; then
|
||||
echo "✓ Cursor plans: $(ls "$SRC"/*.plan.md 2>/dev/null | wc -l) files"
|
||||
cp -u "$SRC"/*.plan.md "$UNIFIED/plans/" 2>/dev/null || true
|
||||
cp -u "$SRC"/*.code-workspace "$UNIFIED/plans/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 3. GoCodeMe Sessions & Data ──────────────────────────────────
|
||||
SRC="$HOME/.gocodeme"
|
||||
if [ -d "$SRC" ]; then
|
||||
echo "✓ GoCodeMe data:"
|
||||
# Chat sessions
|
||||
if [ -d "$SRC/chatSessions" ]; then
|
||||
mkdir -p "$UNIFIED/sessions/gocodeme"
|
||||
cp -u "$SRC/chatSessions/"*.json "$UNIFIED/sessions/gocodeme/" 2>/dev/null || true
|
||||
echo " - Chat sessions: $(ls "$SRC/chatSessions/"*.json 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Playbooks
|
||||
if [ -d "$SRC/playbooks" ]; then
|
||||
cp -u "$SRC/playbooks/"*.json "$UNIFIED/playbooks/" 2>/dev/null || true
|
||||
echo " - Playbooks: $(ls "$SRC/playbooks/"*.json 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Analytics
|
||||
if [ -f "$SRC/analytics/tool_usage.jsonl" ]; then
|
||||
cp -u "$SRC/analytics/tool_usage.jsonl" "$UNIFIED/analytics/" 2>/dev/null || true
|
||||
echo " - Analytics: $(wc -l < "$SRC/analytics/tool_usage.jsonl") tool usage events"
|
||||
fi
|
||||
# AI instructions
|
||||
cp -u "$SRC/ai-instructions.md" "$UNIFIED/memories/gocodeme-ai-instructions.md" 2>/dev/null || true
|
||||
cp -u "$SRC/settings.json" "$UNIFIED/analytics/gocodeme-settings.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 4. Alfred Agent Sessions ─────────────────────────────────────
|
||||
SRC="$HOME/alfred-agent/data"
|
||||
if [ -d "$SRC" ]; then
|
||||
echo "✓ Alfred Agent data:"
|
||||
# Sessions
|
||||
if [ -d "$SRC/sessions" ]; then
|
||||
mkdir -p "$UNIFIED/sessions/alfred-agent"
|
||||
cp -u "$SRC/sessions/"*.json "$UNIFIED/sessions/alfred-agent/" 2>/dev/null || true
|
||||
echo " - Sessions: $(ls "$SRC/sessions/"*.json 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Transcripts
|
||||
if [ -d "$SRC/transcripts" ] && [ "$(ls -A "$SRC/transcripts" 2>/dev/null)" ]; then
|
||||
mkdir -p "$UNIFIED/sessions/alfred-agent/transcripts"
|
||||
cp -ru "$SRC/transcripts/"* "$UNIFIED/sessions/alfred-agent/transcripts/" 2>/dev/null || true
|
||||
echo " - Transcripts: $(ls "$SRC/transcripts/" 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Hook logs
|
||||
if [ -d "$SRC/hook-logs" ]; then
|
||||
cp -u "$SRC/hook-logs/"* "$UNIFIED/hooks/" 2>/dev/null || true
|
||||
echo " - Hook logs: $(ls "$SRC/hook-logs/" 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Memories
|
||||
if [ -d "$SRC/memories" ] && [ "$(ls -A "$SRC/memories" 2>/dev/null)" ]; then
|
||||
cp -u "$SRC/memories/"* "$UNIFIED/memories/" 2>/dev/null || true
|
||||
echo " - Agent memories: $(ls "$SRC/memories/" 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Skills
|
||||
if [ -d "$SRC/skills" ] && [ "$(ls -A "$SRC/skills" 2>/dev/null)" ]; then
|
||||
cp -u "$SRC/skills/"* "$UNIFIED/skills/" 2>/dev/null || true
|
||||
echo " - Skills: $(ls "$SRC/skills/" 2>/dev/null | wc -l)"
|
||||
fi
|
||||
# Costs
|
||||
if [ -d "$SRC/costs" ] && [ "$(ls -A "$SRC/costs" 2>/dev/null)" ]; then
|
||||
mkdir -p "$UNIFIED/analytics/costs"
|
||||
cp -u "$SRC/costs/"* "$UNIFIED/analytics/costs/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5. Strategy Docs ─────────────────────────────────────────────
|
||||
echo "✓ Strategy docs:"
|
||||
for f in \
|
||||
"$HOME/ALFRED_IDE_PLATFORM_PLAN_2026-04-02.md" \
|
||||
"$HOME/ALFRED_LINUX_GRAND_ROADMAP_v4-v9.md" \
|
||||
"$HOME/ALFRED_LINUX_MESH_PLAN_2026-04-04.md" \
|
||||
"$HOME/BULLETPROOF_PLAN_2026-04-05.md" \
|
||||
"$HOME/ECOSYSTEM_LAUNCH_TRIAGE_2026-04-02.md" \
|
||||
"$HOME/LAUNCH_SCOREBOARD_2026-04-02.md" \
|
||||
"$HOME/LAUNCH_SCOREBOARD_2026-04-08.md" \
|
||||
"$HOME/LAUNCH_CHECKPOINT_2026-04-03.md"; do
|
||||
if [ -f "$f" ]; then
|
||||
cp -u "$f" "$UNIFIED/strategy/"
|
||||
echo " - $(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 6. Backup Archive ────────────────────────────────────────────
|
||||
SRC="$HOME/.backup-archive"
|
||||
if [ -d "$SRC" ]; then
|
||||
echo "✓ Backup archive: $(ls "$SRC" | wc -l) files"
|
||||
mkdir -p "$UNIFIED/archives/backup-archive"
|
||||
cp -ru "$SRC/"* "$UNIFIED/archives/backup-archive/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 7. VS Code History Index ─────────────────────────────────────
|
||||
HIST="$HOME/.vscode-server/data/User/History"
|
||||
if [ -d "$HIST" ]; then
|
||||
HIST_COUNT=$(ls "$HIST" | wc -l)
|
||||
echo "✓ VS Code history: $HIST_COUNT edit histories"
|
||||
# Build a lightweight index (don't copy all files)
|
||||
echo "# VS Code Edit History Index" > "$UNIFIED/history-index/vscode-history.md"
|
||||
echo "# Generated: $(date -Iseconds)" >> "$UNIFIED/history-index/vscode-history.md"
|
||||
echo "# Total: $HIST_COUNT files" >> "$UNIFIED/history-index/vscode-history.md"
|
||||
echo "" >> "$UNIFIED/history-index/vscode-history.md"
|
||||
for d in "$HIST"/*/; do
|
||||
if [ -f "$d/entries.json" ]; then
|
||||
resource=$(python3 -c "import sys,json; print(json.load(open('$d/entries.json')).get('resource','?'))" 2>/dev/null || echo "?")
|
||||
entries=$(python3 -c "import sys,json; print(len(json.load(open('$d/entries.json')).get('entries',[])))" 2>/dev/null || echo "?")
|
||||
echo "- $(basename "$d") | $entries edits | $resource" >> "$UNIFIED/history-index/vscode-history.md"
|
||||
fi
|
||||
done 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── 8. VS Code Workspace Storage Index ────────────────────────────
|
||||
WS="$HOME/.vscode-server/data/User/workspaceStorage"
|
||||
if [ -d "$WS" ]; then
|
||||
echo "✓ VS Code workspaces: $(ls "$WS" | wc -l) workspaces"
|
||||
echo "# VS Code Workspace Index" > "$UNIFIED/history-index/vscode-workspaces.md"
|
||||
echo "# Generated: $(date -Iseconds)" >> "$UNIFIED/history-index/vscode-workspaces.md"
|
||||
echo "" >> "$UNIFIED/history-index/vscode-workspaces.md"
|
||||
for d in "$WS"/*/; do
|
||||
if [ -f "$d/workspace.json" ]; then
|
||||
info=$(cat "$d/workspace.json" 2>/dev/null)
|
||||
echo "- $(basename "$d") | $info" >> "$UNIFIED/history-index/vscode-workspaces.md"
|
||||
fi
|
||||
done 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── 9. Copilot Chat Agents & Settings ─────────────────────────────
|
||||
SRC="$HOME/.vscode-server/data/User/globalStorage/github.copilot-chat"
|
||||
if [ -d "$SRC" ]; then
|
||||
cp -u "$SRC/commandEmbeddings.json" "$UNIFIED/analytics/" 2>/dev/null || true
|
||||
cp -u "$SRC/settingEmbeddings.json" "$UNIFIED/analytics/" 2>/dev/null || true
|
||||
# Plan agent + Ask agent
|
||||
mkdir -p "$UNIFIED/skills/copilot-agents"
|
||||
cp -u "$SRC/plan-agent/Plan.agent.md" "$UNIFIED/skills/copilot-agents/" 2>/dev/null || true
|
||||
cp -u "$SRC/ask-agent/Ask.agent.md" "$UNIFIED/skills/copilot-agents/" 2>/dev/null || true
|
||||
echo "✓ Copilot agents & embeddings"
|
||||
fi
|
||||
|
||||
# ── 10. Cline/Claude Dev Data ─────────────────────────────────────
|
||||
SRC="$HOME/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev"
|
||||
if [ -d "$SRC" ]; then
|
||||
mkdir -p "$UNIFIED/analytics/cline"
|
||||
cp -u "$SRC/cache/"*.json "$UNIFIED/analytics/cline/" 2>/dev/null || true
|
||||
cp -u "$SRC/settings/"*.json "$UNIFIED/analytics/cline/" 2>/dev/null || true
|
||||
echo "✓ Cline/Claude Dev data"
|
||||
fi
|
||||
|
||||
# ── 11. Agent Backups ─────────────────────────────────────────────
|
||||
if [ -d "$HOME/backups/alfred-agent" ]; then
|
||||
echo "✓ Agent backups: $(ls "$HOME/backups/alfred-agent" | wc -l) snapshots"
|
||||
# Just create a symlink instead of copying
|
||||
ln -sfn "$HOME/backups/alfred-agent" "$UNIFIED/archives/agent-backups"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " CONSOLIDATION COMPLETE"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Unified workspace: $UNIFIED"
|
||||
echo ""
|
||||
du -sh "$UNIFIED"/* 2>/dev/null | sort -rh | while read size dir; do
|
||||
echo " $size $(basename "$dir")"
|
||||
done
|
||||
echo ""
|
||||
TOTAL=$(find "$UNIFIED" -type f | wc -l)
|
||||
echo " Total files: $TOTAL"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
455
package-lock.json
generated
455
package-lock.json
generated
@ -8,7 +8,8 @@
|
||||
"name": "alfred-agent",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0"
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"better-sqlite3": "^12.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@ -75,6 +76,84 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.8.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@ -88,6 +167,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -100,6 +185,30 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@ -109,6 +218,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -123,6 +241,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@ -177,6 +304,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@ -212,6 +354,12 @@
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@ -258,6 +406,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@ -318,6 +472,38 @@
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -348,12 +534,57 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@ -394,18 +625,234 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
@ -430,6 +877,12 @@
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"test": "node src/test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0"
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"better-sqlite3": "^12.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
268
src/agent.js
268
src/agent.js
@ -1,26 +1,42 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT HARNESS — Core Agent Loop
|
||||
* ALFRED AGENT HARNESS — Core Agent Loop (v2)
|
||||
*
|
||||
* The beating heart of Alfred's sovereign agent runtime.
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* Now powered by:
|
||||
* - 4-tier compaction (micro, auto, session-memory, post-compact)
|
||||
* - Token estimation and context window management
|
||||
* - Streaming responses
|
||||
* - Context tracking (files, git, errors, discoveries)
|
||||
* - Skills engine (auto-invoked on trigger match)
|
||||
* - Agent forking (sub-agent tool)
|
||||
* - Task tracking (create, update, list sub-tasks)
|
||||
* - Steering prompts (tool-specific safety and quality rules)
|
||||
*
|
||||
* This is the loop. User message in → tools execute → results feed back →
|
||||
* loop until done. Simple plumbing, infinite power.
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { getTools, executeTool } from './tools.js';
|
||||
import { getTools, executeTool, registerTool } from './tools.js';
|
||||
import { buildSystemPrompt } from './prompt.js';
|
||||
import { createSession, loadSession, addMessage, getAPIMessages, compactSession, saveSession } from './session.js';
|
||||
import { createSession, loadSession, addMessage, getAPIMessages, saveSession } from './session.js';
|
||||
import { createHookEngine } from './hooks.js';
|
||||
import { createContextTracker } from './services/contextTracker.js';
|
||||
import { createSkillEngine } from './services/skillEngine.js';
|
||||
import { compactIfNeeded, createCompactTracking } from './services/compact.js';
|
||||
import {
|
||||
estimateConversationTokens,
|
||||
calculateTokenWarnings,
|
||||
} from './services/tokenEstimation.js';
|
||||
import { buildSteeringSections, injectToolSteering } from './services/steering.js';
|
||||
import { getAgentTaskTools } from './services/agentFork.js';
|
||||
import { getRedactor } from './services/redact.js';
|
||||
import { createIntent } from './services/intent.js';
|
||||
|
||||
// Max turns before auto-compaction
|
||||
const COMPACTION_THRESHOLD = 40;
|
||||
// Max tool execution rounds per user message
|
||||
const MAX_TOOL_ROUNDS = 25;
|
||||
|
||||
/**
|
||||
* The Agent — Alfred's core runtime.
|
||||
* The Agent — Alfred's core runtime (v2).
|
||||
*
|
||||
* @param {Object} provider - AI provider (from providers.js)
|
||||
* @param {Object} opts - Options
|
||||
@ -29,15 +45,19 @@ const MAX_TOOL_ROUNDS = 25;
|
||||
* @param {string} opts.profile - Hook profile: 'commander' or 'customer'
|
||||
* @param {string} opts.clientId - Customer client ID (for sandbox scoping)
|
||||
* @param {string} opts.workspaceRoot - Customer workspace root dir
|
||||
* @param {boolean} opts.stream - Enable streaming responses
|
||||
* @param {Function} opts.onText - Callback for text output
|
||||
* @param {Function} opts.onToolUse - Callback for tool execution events
|
||||
* @param {Function} opts.onToolResult - Callback for tool results
|
||||
* @param {Function} opts.onError - Callback for errors
|
||||
* @param {Function} opts.onCompactProgress - Callback for compaction progress
|
||||
* @param {Function} opts.onTokenWarning - Callback for token warnings
|
||||
* @param {Function} opts.onHookEvent - Callback for hook events
|
||||
*/
|
||||
export function createAgent(provider, opts = {}) {
|
||||
const tools = getTools();
|
||||
export async function createAgent(provider, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
|
||||
// Initialize or resume session
|
||||
// ── Initialize or resume session ──────────────────────────────────
|
||||
let session;
|
||||
if (opts.sessionId) {
|
||||
session = loadSession(opts.sessionId);
|
||||
@ -49,7 +69,14 @@ export function createAgent(provider, opts = {}) {
|
||||
session = createSession();
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt({ tools, sessionId: session.id, cwd });
|
||||
// ── Services ──────────────────────────────────────────────────────
|
||||
const contextTracker = createContextTracker(opts.workspaceRoot || cwd);
|
||||
const skillEngine = createSkillEngine();
|
||||
const compactTracking = createCompactTracking();
|
||||
|
||||
// ── Brain services (Omahon pattern ports) ─────────────────────────
|
||||
const redactor = getRedactor();
|
||||
const intent = createIntent(session.id);
|
||||
|
||||
// Hook engine — gates all tool execution
|
||||
const hookEngine = opts.hookEngine || createHookEngine(opts.profile || 'commander', {
|
||||
@ -58,7 +85,33 @@ export function createAgent(provider, opts = {}) {
|
||||
onHookEvent: opts.onHookEvent,
|
||||
});
|
||||
|
||||
// Callbacks
|
||||
// ── Register dynamic tools ────────────────────────────────────────
|
||||
const agentTaskTools = getAgentTaskTools(provider, session.id);
|
||||
for (const tool of agentTaskTools) {
|
||||
registerTool(tool);
|
||||
}
|
||||
const allTools = getTools();
|
||||
|
||||
// ── Apply steering to tool descriptions ───────────────────────────
|
||||
const steeredTools = injectToolSteering(allTools);
|
||||
|
||||
// ── Build system prompt ───────────────────────────────────────────
|
||||
const steeringSections = buildSteeringSections();
|
||||
const skillListing = skillEngine.getListing();
|
||||
const basePromptSections = await buildSystemPrompt({
|
||||
tools: steeredTools,
|
||||
sessionId: session.id,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const systemPrompt = [
|
||||
...basePromptSections,
|
||||
...steeringSections,
|
||||
skillListing,
|
||||
intent.render(),
|
||||
].filter(Boolean);
|
||||
|
||||
// ── Callbacks ─────────────────────────────────────────────────────
|
||||
const onText = opts.onText || (text => process.stdout.write(text));
|
||||
const onToolUse = opts.onToolUse || ((name, input) => {
|
||||
console.error(`\x1b[36m⚡ Tool: ${name}\x1b[0m`);
|
||||
@ -68,26 +121,66 @@ export function createAgent(provider, opts = {}) {
|
||||
console.error(`\x1b[32m✓ ${name}: ${preview}\x1b[0m`);
|
||||
});
|
||||
const onError = opts.onError || (err => console.error(`\x1b[31m✗ Error: ${err}\x1b[0m`));
|
||||
const onCompactProgress = opts.onCompactProgress || ((event) => {
|
||||
if (event.type === 'compact_start') console.error('\x1b[33m📦 Compacting session...\x1b[0m');
|
||||
else if (event.type === 'compact_done') console.error(`\x1b[33m📦 Compacted: freed ${event.tokensFreed} tokens\x1b[0m`);
|
||||
else if (event.type === 'micro_compact') console.error(`\x1b[33m🔬 Micro-compact: freed ${event.tokensFreed} tokens\x1b[0m`);
|
||||
});
|
||||
const onTokenWarning = opts.onTokenWarning || ((warning) => {
|
||||
if (warning.isWarning) console.error(`\x1b[33m⚠ Context: ${warning.percentUsed}% used (${warning.percentLeft}% left)\x1b[0m`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Process a user message through the agent loop.
|
||||
* This is the core — the while loop with tools.
|
||||
* This is the core — the while loop with tools, compaction, and streaming.
|
||||
*/
|
||||
async function processMessage(userMessage) {
|
||||
// Add user message to session
|
||||
// ── Add user message to session ───────────────────────────────
|
||||
addMessage(session, 'user', userMessage);
|
||||
|
||||
// Check if we need to compact
|
||||
if (session.messages.length > COMPACTION_THRESHOLD) {
|
||||
console.error('\x1b[33m📦 Compacting session...\x1b[0m');
|
||||
compactSession(session);
|
||||
// ── Match skills ──────────────────────────────────────────────
|
||||
const skillPrompts = skillEngine.getActiveSkillPrompts(
|
||||
typeof userMessage === 'string' ? userMessage : '',
|
||||
);
|
||||
|
||||
const effectiveSystemPrompt = skillPrompts.length > 0
|
||||
? [...systemPrompt, ...skillPrompts]
|
||||
: systemPrompt;
|
||||
|
||||
// ── Check compaction BEFORE querying ───────────────────────────
|
||||
const compactResult = await compactIfNeeded(
|
||||
session.messages,
|
||||
provider,
|
||||
provider.model,
|
||||
compactTracking,
|
||||
{
|
||||
sessionId: session.id,
|
||||
fileTracker: contextTracker,
|
||||
activeSkills: skillEngine.getInvokedSkills(),
|
||||
onProgress: onCompactProgress,
|
||||
},
|
||||
);
|
||||
|
||||
if (compactResult.wasCompacted) {
|
||||
session.messages = compactResult.messages;
|
||||
session.compacted = true;
|
||||
if (compactResult.summary) session.summary = compactResult.summary;
|
||||
saveSession(session);
|
||||
}
|
||||
|
||||
// ── Token warning check ───────────────────────────────────────
|
||||
const tokenUsage = estimateConversationTokens(getAPIMessages(session));
|
||||
const warnings = calculateTokenWarnings(tokenUsage, provider.model);
|
||||
if (warnings.isWarning) {
|
||||
onTokenWarning(warnings);
|
||||
}
|
||||
|
||||
let round = 0;
|
||||
let lastModel = null;
|
||||
let lastUsage = null;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// THE LOOP — This is it. The agent loop. Simple and powerful.
|
||||
// THE LOOP — Now with streaming and compaction.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
while (round < MAX_TOOL_ROUNDS) {
|
||||
round++;
|
||||
@ -95,12 +188,21 @@ export function createAgent(provider, opts = {}) {
|
||||
// 1. Send messages to the AI provider
|
||||
let response;
|
||||
try {
|
||||
response = await provider.query({
|
||||
systemPrompt,
|
||||
const queryParams = {
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
messages: getAPIMessages(session),
|
||||
tools,
|
||||
tools: steeredTools,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
};
|
||||
|
||||
if (opts.stream && provider.streamQuery) {
|
||||
response = await provider.streamQuery(queryParams, {
|
||||
onText,
|
||||
onToolUse: (name) => onToolUse(name, {}),
|
||||
});
|
||||
} else {
|
||||
response = await provider.query(queryParams);
|
||||
}
|
||||
} catch (err) {
|
||||
onError(`Provider error: ${err.message}`);
|
||||
break;
|
||||
@ -109,6 +211,7 @@ export function createAgent(provider, opts = {}) {
|
||||
// Track usage
|
||||
if (response.usage) {
|
||||
session.totalTokensUsed += (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0);
|
||||
lastUsage = response.usage;
|
||||
}
|
||||
lastModel = response.model || lastModel;
|
||||
|
||||
@ -120,38 +223,51 @@ export function createAgent(provider, opts = {}) {
|
||||
for (const block of assistantContent) {
|
||||
if (block.type === 'text') {
|
||||
textParts.push(block.text);
|
||||
onText(block.text);
|
||||
if (!opts.stream || !provider.streamQuery) {
|
||||
onText(block.text);
|
||||
}
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolUseBlocks.push(block);
|
||||
onToolUse(block.name, block.input);
|
||||
if (!opts.stream || !provider.streamQuery) {
|
||||
onToolUse(block.name, block.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save assistant response to session
|
||||
addMessage(session, 'assistant', assistantContent);
|
||||
|
||||
// 3. If no tool calls, we're done — the model finished its response
|
||||
// ── Brain: parse ambient tags + track assistant text ───────
|
||||
for (const part of textParts) {
|
||||
intent.parseAmbient(part);
|
||||
}
|
||||
|
||||
// 3. If no tool calls, we're done
|
||||
if (response.stopReason !== 'tool_use' || toolUseBlocks.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 4. Execute all tool calls — WITH HOOK GATES
|
||||
// 4. Execute all tool calls — WITH HOOK GATES + CONTEXT TRACKING
|
||||
const toolResults = [];
|
||||
for (const toolCall of toolUseBlocks) {
|
||||
// ── PreToolUse Hook ──────────────────────────────────
|
||||
// ── Context tracking ─────────────────────────────────────
|
||||
trackToolContext(contextTracker, toolCall.name, toolCall.input);
|
||||
|
||||
// ── Intent tracking (brain) ──────────────────────────────
|
||||
intent.trackToolUse(toolCall.name, toolCall.input);
|
||||
|
||||
// ── PreToolUse Hook ──────────────────────────────────────
|
||||
const preResult = await hookEngine.runPreToolUse(toolCall.name, toolCall.input);
|
||||
|
||||
let result;
|
||||
if (preResult.action === 'block') {
|
||||
// Hook blocked the tool — tell the model why
|
||||
result = { error: `BLOCKED by policy: ${preResult.reason}` };
|
||||
onError(`Hook blocked ${toolCall.name}: ${preResult.reason}`);
|
||||
} else {
|
||||
// Use potentially modified input from hooks
|
||||
const finalInput = preResult.input || toolCall.input;
|
||||
result = await executeTool(toolCall.name, finalInput);
|
||||
|
||||
// ── PostToolUse Hook ─────────────────────────────────
|
||||
// ── PostToolUse Hook ─────────────────────────────────────
|
||||
const postResult = await hookEngine.runPostToolUse(toolCall.name, finalInput, result);
|
||||
if (postResult.result !== undefined) {
|
||||
result = postResult.result;
|
||||
@ -162,28 +278,60 @@ export function createAgent(provider, opts = {}) {
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolCall.id,
|
||||
content: JSON.stringify(result),
|
||||
content: redactor.redact(JSON.stringify(result)),
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Feed tool results back as user message (Anthropic API format)
|
||||
// 5. Feed tool results back as user message
|
||||
addMessage(session, 'user', toolResults);
|
||||
|
||||
// Loop continues — the model will process tool results and decide
|
||||
// whether to call more tools or respond to the user
|
||||
// 6. Check compaction between tool rounds (every 5 rounds)
|
||||
if (round % 5 === 0) {
|
||||
const midLoopCompact = await compactIfNeeded(
|
||||
session.messages,
|
||||
provider,
|
||||
provider.model,
|
||||
compactTracking,
|
||||
{
|
||||
sessionId: session.id,
|
||||
fileTracker: contextTracker,
|
||||
activeSkills: skillEngine.getInvokedSkills(),
|
||||
onProgress: onCompactProgress,
|
||||
},
|
||||
);
|
||||
if (midLoopCompact.wasCompacted) {
|
||||
session.messages = midLoopCompact.messages;
|
||||
session.compacted = true;
|
||||
saveSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (round >= MAX_TOOL_ROUNDS) {
|
||||
onError(`Hit max tool rounds (${MAX_TOOL_ROUNDS}). Stopping.`);
|
||||
}
|
||||
|
||||
compactTracking.turnCounter++;
|
||||
saveSession(session);
|
||||
|
||||
// ── Brain: persist intent state ──────────────────────────────
|
||||
intent.incrementTurn();
|
||||
if (lastUsage) {
|
||||
intent.addTokens((lastUsage.input_tokens || 0) + (lastUsage.output_tokens || 0));
|
||||
}
|
||||
intent.save();
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
turns: session.turnCount,
|
||||
tokensUsed: session.totalTokensUsed,
|
||||
model: lastModel || provider.model,
|
||||
context: {
|
||||
estimatedTokens: estimateConversationTokens(getAPIMessages(session)),
|
||||
messageCount: session.messages.length,
|
||||
compacted: session.compacted || false,
|
||||
filesTracked: contextTracker.getTopFiles(10).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -191,6 +339,50 @@ export function createAgent(provider, opts = {}) {
|
||||
processMessage,
|
||||
getSession: () => session,
|
||||
getSessionId: () => session.id,
|
||||
compact: () => compactSession(session),
|
||||
compact: async () => {
|
||||
const result = await compactIfNeeded(
|
||||
session.messages,
|
||||
provider,
|
||||
provider.model,
|
||||
{ ...compactTracking, consecutiveFailures: 0 },
|
||||
{
|
||||
sessionId: session.id,
|
||||
fileTracker: contextTracker,
|
||||
activeSkills: skillEngine.getInvokedSkills(),
|
||||
onProgress: onCompactProgress,
|
||||
},
|
||||
);
|
||||
if (result.wasCompacted) {
|
||||
session.messages = result.messages;
|
||||
session.compacted = true;
|
||||
saveSession(session);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
getContextTracker: () => contextTracker,
|
||||
getSkillEngine: () => skillEngine,
|
||||
getTokenUsage: () => estimateConversationTokens(getAPIMessages(session)),
|
||||
getTokenWarnings: () => calculateTokenWarnings(
|
||||
estimateConversationTokens(getAPIMessages(session)),
|
||||
provider.model,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track tool usage in the context tracker.
|
||||
*/
|
||||
function trackToolContext(tracker, toolName, input) {
|
||||
switch (toolName) {
|
||||
case 'read_file':
|
||||
if (input.path) tracker.trackRead(input.path);
|
||||
break;
|
||||
case 'write_file':
|
||||
case 'edit_file':
|
||||
if (input.path) tracker.trackWrite(input.path);
|
||||
break;
|
||||
case 'bash':
|
||||
tracker.trackCommand(input.command || '', input.cwd);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
76
src/cli.js
76
src/cli.js
@ -1,13 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Interactive CLI
|
||||
* ALFRED AGENT — Interactive CLI (v2)
|
||||
*
|
||||
* Usage:
|
||||
* node src/cli.js # New session
|
||||
* node src/cli.js --resume <id> # Resume session
|
||||
* node src/cli.js --sessions # List sessions
|
||||
* node src/cli.js -m "message" # Single message mode
|
||||
* node src/cli.js --stream # Enable streaming responses
|
||||
*
|
||||
* REPL commands: /tokens /context /skills /compact /session /quit /help
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { createInterface } from 'readline';
|
||||
@ -25,6 +28,7 @@ for (let i = 0; i < args.length; i++) {
|
||||
else if (args[i] === '--model') flags.model = args[++i];
|
||||
else if (args[i] === '--provider') flags.provider = args[++i];
|
||||
else if (args[i] === '--profile') flags.profile = args[++i];
|
||||
else if (args[i] === '--stream') flags.stream = true;
|
||||
else if (args[i] === '--help' || args[i] === '-h') flags.help = true;
|
||||
}
|
||||
|
||||
@ -42,6 +46,16 @@ if (flags.help) {
|
||||
alfred-agent -s List sessions
|
||||
alfred-agent --model opus Use specific model
|
||||
alfred-agent --provider groq Use specific provider
|
||||
alfred-agent --stream Stream responses in real-time
|
||||
|
||||
REPL Commands:
|
||||
/tokens Show token usage and context window warnings
|
||||
/context Show tracked files and git status
|
||||
/skills List loaded skills
|
||||
/compact Force compaction to free context
|
||||
/session Show current session info
|
||||
/sessions List recent sessions
|
||||
/quit Exit
|
||||
|
||||
Providers:
|
||||
anthropic (default) — Claude (needs ANTHROPIC_API_KEY)
|
||||
@ -99,10 +113,11 @@ try {
|
||||
}
|
||||
|
||||
// ── Create agent ─────────────────────────────────────────────────────
|
||||
const agent = createAgent(provider, {
|
||||
const agent = await createAgent(provider, {
|
||||
sessionId: flags.resume,
|
||||
cwd: process.cwd(),
|
||||
profile: flags.profile || 'commander',
|
||||
stream: !!flags.stream,
|
||||
onText: (text) => process.stdout.write(text),
|
||||
onToolUse: (name, input) => {
|
||||
console.error(`\n\x1b[36m\u26a1 ${name}\x1b[0m ${JSON.stringify(input).slice(0, 120)}`);
|
||||
@ -116,12 +131,20 @@ const agent = createAgent(provider, {
|
||||
console.error(`\x1b[32m✓ ${name}\x1b[0m (${str.length} bytes)`);
|
||||
},
|
||||
onError: (err) => console.error(`\x1b[31m✗ ${err}\x1b[0m`),
|
||||
onCompactProgress: (evt) => {
|
||||
if (evt.type === 'compact_start') console.error('\x1b[33m📦 Compacting session...\x1b[0m');
|
||||
else if (evt.type === 'compact_done') console.error(`\x1b[33m📦 Compacted: freed ${evt.tokensFreed} tokens\x1b[0m`);
|
||||
else if (evt.type === 'micro_compact') console.error(`\x1b[33m🔬 Micro-compact: freed ${evt.tokensFreed} tokens\x1b[0m`);
|
||||
},
|
||||
onTokenWarning: (warning) => {
|
||||
if (warning.isWarning) console.error(`\x1b[33m⚠ Context: ${warning.percentUsed}% used (${warning.percentLeft}% left)\x1b[0m`);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Banner ───────────────────────────────────────────────────────────
|
||||
console.log(`
|
||||
\x1b[36m╔═══════════════════════════════════════════════════════════╗
|
||||
║ ALFRED AGENT v1.0.0 — Sovereign AI Runtime ║
|
||||
║ ALFRED AGENT v2.0.0 — Sovereign AI Runtime ║
|
||||
║ Provider: ${provider.name.padEnd(15)} Model: ${provider.model.padEnd(20)}║
|
||||
║ Session: ${agent.getSessionId().padEnd(46)}║
|
||||
╚═══════════════════════════════════════════════════════════╝\x1b[0m
|
||||
@ -164,8 +187,44 @@ rl.on('line', async (line) => {
|
||||
return;
|
||||
}
|
||||
if (input === '/compact') {
|
||||
agent.compact();
|
||||
console.log('Session compacted.');
|
||||
try {
|
||||
const result = await agent.compact();
|
||||
console.log(`Session compacted. ${result.wasCompacted ? 'Freed tokens.' : 'No compaction needed.'}`);
|
||||
} catch (e) {
|
||||
console.error(`Compact error: ${e.message}`);
|
||||
}
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
if (input === '/tokens') {
|
||||
const usage = agent.getTokenUsage();
|
||||
const warnings = agent.getTokenWarnings();
|
||||
console.log(` Estimated tokens: ${usage}`);
|
||||
console.log(` Context used: ${warnings.percentUsed}% | Left: ${warnings.percentLeft}%`);
|
||||
if (warnings.isWarning) console.log(` \x1b[33m⚠ Warning: context window filling up\x1b[0m`);
|
||||
if (warnings.shouldCompact) console.log(` \x1b[31m⚠ Auto-compact recommended\x1b[0m`);
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
if (input === '/context') {
|
||||
const snapshot = agent.getContextTracker().getSnapshot();
|
||||
console.log(` Top files accessed:`);
|
||||
for (const f of snapshot.topFiles || []) {
|
||||
console.log(` ${f.path} (reads:${f.reads}, writes:${f.writes})`);
|
||||
}
|
||||
if (snapshot.recentCommands?.length) {
|
||||
console.log(` Recent commands: ${snapshot.recentCommands.length}`);
|
||||
}
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
if (input === '/skills') {
|
||||
const skills = agent.getSkillEngine().getSkills();
|
||||
if (skills.length === 0) {
|
||||
console.log(' No skills loaded. Place SKILL.md files in ~/alfred-agent/data/skills/');
|
||||
} else {
|
||||
for (const s of skills) console.log(` ${s.name} — ${s.description || 'no description'}`);
|
||||
}
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
@ -181,6 +240,9 @@ rl.on('line', async (line) => {
|
||||
/quit, /exit Exit
|
||||
/session Show current session info
|
||||
/sessions List recent sessions
|
||||
/tokens Show token usage & context window
|
||||
/context Show tracked files & git status
|
||||
/skills List loaded skills
|
||||
/compact Compact session to free context
|
||||
/help This help
|
||||
`);
|
||||
@ -191,7 +253,9 @@ rl.on('line', async (line) => {
|
||||
try {
|
||||
console.log(); // Blank line before response
|
||||
const result = await agent.processMessage(input);
|
||||
console.log(`\n\x1b[33m[turn ${result.turns} | ${result.tokensUsed} tokens]\x1b[0m\n`);
|
||||
const ctx = result.context || {};
|
||||
const ctxInfo = ctx.compacted ? ' | compacted' : '';
|
||||
console.log(`\n\x1b[33m[turn ${result.turns} | ${result.tokensUsed} tokens | ~${ctx.estimatedTokens || '?'} ctx${ctxInfo}]\x1b[0m\n`);
|
||||
} catch (err) {
|
||||
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
||||
}
|
||||
|
||||
188
src/index.js
188
src/index.js
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — HTTP Server
|
||||
* ALFRED AGENT — HTTP Server (v2)
|
||||
*
|
||||
* Exposes the agent harness via HTTP API for integration with:
|
||||
* - Alfred IDE chat panel
|
||||
@ -8,17 +8,29 @@
|
||||
* - Voice AI pipeline
|
||||
* - Any internal service
|
||||
*
|
||||
* New in v2:
|
||||
* - /chat/stream — Server-Sent Events streaming
|
||||
* - /context — Context tracker snapshot
|
||||
* - /tokens — Token usage and warnings
|
||||
* - /skills — List and reload skills
|
||||
* - /tasks — Task management endpoints
|
||||
* - /compact — Manual compaction trigger
|
||||
*
|
||||
* Binds to 127.0.0.1 only — not exposed to internet.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { createServer } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
|
||||
import { createAgent } from './agent.js';
|
||||
import { createAnthropicProvider, createOpenAICompatProvider } from './providers.js';
|
||||
import { listSessions } from './session.js';
|
||||
import { listTasks } from './services/agentFork.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || process.env.ALFRED_AGENT_PORT || '3102', 10);
|
||||
const HOST = '127.0.0.1'; // Localhost only — never expose to internet
|
||||
const INTERNAL_SECRET = process.env.INTERNAL_SECRET || 'ee16f048838d22d2c2d54099ea109cd612ed919ddaf1c14b8eb8670214ab0d69';
|
||||
const VAULT_DIR = `${process.env.HOME}/.vault/keys`;
|
||||
|
||||
// Active agents keyed by session ID
|
||||
const agents = new Map();
|
||||
@ -38,19 +50,22 @@ function getOrCreateProvider(providerName = 'anthropic', model) {
|
||||
return createAnthropicProvider({ model });
|
||||
}
|
||||
|
||||
function getOrCreateAgent(sessionId, providerName, model) {
|
||||
async function getOrCreateAgent(sessionId, providerName, model, extraOpts = {}) {
|
||||
if (sessionId && agents.has(sessionId)) return agents.get(sessionId);
|
||||
|
||||
const provider = getOrCreateProvider(providerName, model);
|
||||
const textChunks = [];
|
||||
const toolEvents = [];
|
||||
|
||||
const agent = createAgent(provider, {
|
||||
const agent = await createAgent(provider, {
|
||||
sessionId,
|
||||
stream: extraOpts.stream || false,
|
||||
onText: (text) => textChunks.push(text),
|
||||
onToolUse: (name, input) => toolEvents.push({ type: 'tool_use', name, input }),
|
||||
onToolResult: (name, result) => toolEvents.push({ type: 'tool_result', name, result }),
|
||||
onError: (err) => toolEvents.push({ type: 'error', message: err }),
|
||||
onCompactProgress: (evt) => toolEvents.push({ type: 'compact', ...evt }),
|
||||
onTokenWarning: (warning) => toolEvents.push({ type: 'token_warning', ...warning }),
|
||||
});
|
||||
|
||||
agents.set(agent.getSessionId(), { agent, textChunks, toolEvents });
|
||||
@ -60,7 +75,7 @@ function getOrCreateAgent(sessionId, providerName, model) {
|
||||
function sendJSON(res, status, data) {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Alfred-Agent': 'v1.0.0',
|
||||
'X-Alfred-Agent': 'v2.0.0',
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
@ -81,9 +96,10 @@ const server = createServer(async (req, res) => {
|
||||
return sendJSON(res, 200, {
|
||||
status: 'online',
|
||||
agent: 'Alfred Agent Harness',
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
activeSessions: agents.size,
|
||||
uptime: process.uptime(),
|
||||
features: ['compaction', 'streaming', 'skills', 'steering', 'agent-fork', 'context-tracking', 'secret-redaction', 'intent-tracking', 'decay-memory'],
|
||||
});
|
||||
}
|
||||
|
||||
@ -99,7 +115,7 @@ const server = createServer(async (req, res) => {
|
||||
|
||||
if (!message) return sendJSON(res, 400, { error: 'message is required' });
|
||||
|
||||
const { agent, textChunks, toolEvents } = getOrCreateAgent(sessionId, providerName, model);
|
||||
const { agent, textChunks, toolEvents } = await getOrCreateAgent(sessionId, providerName, model);
|
||||
|
||||
// Clear buffers
|
||||
textChunks.length = 0;
|
||||
@ -117,6 +133,149 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Vault: key management ──────────────────────────────────────
|
||||
if (path === '/vault/status' && req.method === 'GET') {
|
||||
const auth = req.headers['x-internal-secret'] || url.searchParams.get('secret');
|
||||
if (auth !== INTERNAL_SECRET) return sendJSON(res, 403, { error: 'Forbidden' });
|
||||
|
||||
const keys = {};
|
||||
const providers = ['anthropic', 'openai', 'groq', 'xai'];
|
||||
for (const name of providers) {
|
||||
const vaultPath = `${VAULT_DIR}/${name}.key`;
|
||||
const tmpfsPath = `/run/user/1004/keys/${name}.key`;
|
||||
try {
|
||||
const k = readFileSync(vaultPath, 'utf8').trim();
|
||||
keys[name] = { status: 'loaded', source: 'vault', prefix: k.substring(0, 12) + '...', length: k.length };
|
||||
} catch {
|
||||
try {
|
||||
const k = readFileSync(tmpfsPath, 'utf8').trim();
|
||||
keys[name] = { status: 'loaded', source: 'tmpfs', prefix: k.substring(0, 12) + '...', length: k.length };
|
||||
} catch {
|
||||
keys[name] = { status: 'missing' };
|
||||
}
|
||||
}
|
||||
}
|
||||
return sendJSON(res, 200, { vault: VAULT_DIR, keys });
|
||||
}
|
||||
|
||||
if (path === '/vault/set' && req.method === 'POST') {
|
||||
const auth = req.headers['x-internal-secret'];
|
||||
if (auth !== INTERNAL_SECRET) return sendJSON(res, 403, { error: 'Forbidden' });
|
||||
|
||||
const body = await readBody(req);
|
||||
const { name, key } = JSON.parse(body);
|
||||
if (!name || !key) return sendJSON(res, 400, { error: 'name and key are required' });
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) return sendJSON(res, 400, { error: 'Invalid key name' });
|
||||
if (key.length < 10 || key.length > 500) return sendJSON(res, 400, { error: 'Key length must be 10-500 chars' });
|
||||
|
||||
mkdirSync(VAULT_DIR, { recursive: true, mode: 0o700 });
|
||||
const keyPath = `${VAULT_DIR}/${name}.key`;
|
||||
writeFileSync(keyPath, key.trim() + '\n', { mode: 0o600 });
|
||||
return sendJSON(res, 200, { ok: true, saved: keyPath, prefix: key.substring(0, 12) + '...' });
|
||||
}
|
||||
|
||||
// ── Chat with streaming (SSE) ─────────────────────────────────
|
||||
if (path === '/chat/stream' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const { message, sessionId, provider: providerName, model } = JSON.parse(body);
|
||||
|
||||
if (!message) return sendJSON(res, 400, { error: 'message is required' });
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Alfred-Agent': 'v2.0.0',
|
||||
});
|
||||
|
||||
const sendSSE = (event, data) => {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
const provider = getOrCreateProvider(providerName, model);
|
||||
const agent = await createAgent(provider, {
|
||||
sessionId,
|
||||
stream: true,
|
||||
onText: (text) => sendSSE('text', { text }),
|
||||
onToolUse: (name, input) => sendSSE('tool_use', { name, input }),
|
||||
onToolResult: (name, result) => sendSSE('tool_result', { name, result: JSON.stringify(result).slice(0, 2000) }),
|
||||
onError: (err) => sendSSE('error', { message: err }),
|
||||
onCompactProgress: (evt) => sendSSE('compact', evt),
|
||||
onTokenWarning: (warning) => sendSSE('token_warning', warning),
|
||||
});
|
||||
|
||||
agents.set(agent.getSessionId(), { agent, textChunks: [], toolEvents: [] });
|
||||
|
||||
try {
|
||||
const result = await agent.processMessage(message);
|
||||
sendSSE('done', {
|
||||
sessionId: agent.getSessionId(),
|
||||
turns: result.turns,
|
||||
tokensUsed: result.tokensUsed,
|
||||
model: result.model,
|
||||
context: result.context,
|
||||
});
|
||||
} catch (err) {
|
||||
sendSSE('error', { message: err.message });
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Context tracker snapshot ────────────────────────────────────
|
||||
if (path === '/context' && req.method === 'GET') {
|
||||
const sid = url.searchParams.get('sessionId');
|
||||
if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' });
|
||||
const { agent } = agents.get(sid);
|
||||
return sendJSON(res, 200, agent.getContextTracker().getSnapshot());
|
||||
}
|
||||
|
||||
// ── Token usage & warnings ─────────────────────────────────────
|
||||
if (path === '/tokens' && req.method === 'GET') {
|
||||
const sid = url.searchParams.get('sessionId');
|
||||
if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' });
|
||||
const { agent } = agents.get(sid);
|
||||
return sendJSON(res, 200, {
|
||||
usage: agent.getTokenUsage(),
|
||||
warnings: agent.getTokenWarnings(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Skills — list or reload ────────────────────────────────────
|
||||
if (path === '/skills' && req.method === 'GET') {
|
||||
const sid = url.searchParams.get('sessionId');
|
||||
if (sid && agents.has(sid)) {
|
||||
const { agent } = agents.get(sid);
|
||||
return sendJSON(res, 200, { skills: agent.getSkillEngine().getSkills() });
|
||||
}
|
||||
return sendJSON(res, 200, { skills: [], note: 'No session specified or session not found' });
|
||||
}
|
||||
|
||||
if (path === '/skills/reload' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const { sessionId: sid } = JSON.parse(body);
|
||||
if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' });
|
||||
const { agent } = agents.get(sid);
|
||||
agent.getSkillEngine().reload();
|
||||
return sendJSON(res, 200, { ok: true, skills: agent.getSkillEngine().getSkills().length });
|
||||
}
|
||||
|
||||
// ── Tasks — list or create ─────────────────────────────────────
|
||||
if (path === '/tasks' && req.method === 'GET') {
|
||||
const tasks = listTasks();
|
||||
return sendJSON(res, 200, { tasks });
|
||||
}
|
||||
|
||||
// ── Manual compaction trigger ──────────────────────────────────
|
||||
if (path === '/compact' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const { sessionId: sid } = JSON.parse(body);
|
||||
if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' });
|
||||
const { agent } = agents.get(sid);
|
||||
const result = await agent.compact();
|
||||
return sendJSON(res, 200, result);
|
||||
}
|
||||
|
||||
// ── 404 ────────────────────────────────────────────────────────
|
||||
sendJSON(res, 404, { error: 'Not found' });
|
||||
|
||||
@ -138,13 +297,22 @@ function readBody(req) {
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ ALFRED AGENT SERVER v1.0.0 ║
|
||||
║ ALFRED AGENT SERVER v2.0.0 ║
|
||||
║ Listening on ${HOST}:${PORT} ║
|
||||
║ ║
|
||||
║ Endpoints: ║
|
||||
║ GET /health — Health check ║
|
||||
║ GET /sessions — List sessions ║
|
||||
║ POST /chat — Send a message ║
|
||||
║ GET /health — Health check + features ║
|
||||
║ GET /sessions — List sessions ║
|
||||
║ POST /chat — Send a message ║
|
||||
║ POST /chat/stream — Streaming SSE chat ║
|
||||
║ GET /context — Context tracker snapshot ║
|
||||
║ GET /tokens — Token usage & warnings ║
|
||||
║ GET /skills — List skills ║
|
||||
║ POST /skills/reload — Reload skills from disk ║
|
||||
║ GET /tasks — List tasks ║
|
||||
║ POST /compact — Manual compaction trigger ║
|
||||
║ GET /vault/status — Show loaded API keys ║
|
||||
║ POST /vault/set — Store an API key ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
@ -15,7 +15,7 @@ const HOME = homedir();
|
||||
* Build the complete system prompt from layered sections.
|
||||
* Sections are composed dynamically based on context.
|
||||
*/
|
||||
export function buildSystemPrompt({ tools = [], sessionId = null, cwd = null }) {
|
||||
export async function buildSystemPrompt({ tools = [], sessionId = null, cwd = null }) {
|
||||
const sections = [
|
||||
getIdentitySection(),
|
||||
getCommanderSection(),
|
||||
@ -25,7 +25,7 @@ export function buildSystemPrompt({ tools = [], sessionId = null, cwd = null })
|
||||
getActionsSection(),
|
||||
getToneSection(),
|
||||
getEnvironmentSection(cwd),
|
||||
getMemorySection(),
|
||||
await getMemorySection(),
|
||||
getSessionSection(sessionId),
|
||||
].filter(Boolean);
|
||||
|
||||
@ -137,22 +137,35 @@ function getEnvironmentSection(cwd) {
|
||||
- Runtime: Node.js ${process.version}`;
|
||||
}
|
||||
|
||||
function getMemorySection() {
|
||||
async function getMemorySection() {
|
||||
const memDir = join(HOME, 'alfred-agent', 'data', 'memories');
|
||||
if (!existsSync(memDir)) return null;
|
||||
|
||||
const files = readdirSync(memDir).filter(f => f.endsWith('.md'));
|
||||
if (files.length === 0) return null;
|
||||
const parts = [];
|
||||
|
||||
// Load all memories (keep it compact)
|
||||
const memories = files.map(f => {
|
||||
const content = readFileSync(join(memDir, f), 'utf8');
|
||||
return content.slice(0, 2000); // Cap each memory at 2K
|
||||
}).join('\n---\n');
|
||||
// 1. Flat file memories (legacy, backward compat)
|
||||
if (existsSync(memDir)) {
|
||||
const files = readdirSync(memDir).filter(f => f.endsWith('.md'));
|
||||
if (files.length > 0) {
|
||||
const memories = files.map(f => {
|
||||
const content = readFileSync(join(memDir, f), 'utf8');
|
||||
return content.slice(0, 2000);
|
||||
}).join('\n---\n');
|
||||
parts.push(memories);
|
||||
}
|
||||
}
|
||||
|
||||
return `# Persistent Memories
|
||||
// 2. Decay-aware memory from SQLite factstore (Omahon pattern)
|
||||
try {
|
||||
// Dynamic import since this may not be available yet
|
||||
const mod = await import('./services/decayMemory.js');
|
||||
const store = mod.createMemoryStore();
|
||||
const context = store.renderContext('default', 25);
|
||||
if (context) parts.push(context);
|
||||
store.close();
|
||||
} catch { /* decayMemory not available yet */ }
|
||||
|
||||
${memories}`;
|
||||
if (parts.length === 0) return null;
|
||||
return `# Persistent Memories\n\n${parts.join('\n\n')}`;
|
||||
}
|
||||
|
||||
function getSessionSection(sessionId) {
|
||||
|
||||
117
src/providers.js
117
src/providers.js
@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Alfred Agent Harness — Provider Abstraction
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Provider Abstraction (v2)
|
||||
*
|
||||
* Multi-provider support: Anthropic, OpenAI-compat (Groq, xAI, etc.), local Ollama.
|
||||
* Reads API keys from vault (tmpfs) at runtime — never hardcoded.
|
||||
* Multi-provider support with streaming:
|
||||
* - Anthropic Claude (streaming + non-streaming)
|
||||
* - OpenAI-compatible (Groq, xAI, local, etc.)
|
||||
*
|
||||
* Reads API keys from vault (tmpfs) at runtime — never hardcoded.
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { readFileSync } from 'fs';
|
||||
@ -18,7 +24,7 @@ function loadKeyFromVault(name) {
|
||||
return process.env[`${name.toUpperCase()}_API_KEY`] || null;
|
||||
}
|
||||
|
||||
/** Anthropic Claude provider */
|
||||
/** Anthropic Claude provider — with streaming support */
|
||||
export function createAnthropicProvider(opts = {}) {
|
||||
const apiKey = opts.apiKey || loadKeyFromVault('anthropic') || process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error('No Anthropic API key found. Set ANTHROPIC_API_KEY or save to /run/user/1004/keys/anthropic.key');
|
||||
@ -29,7 +35,9 @@ export function createAnthropicProvider(opts = {}) {
|
||||
return {
|
||||
name: 'anthropic',
|
||||
model,
|
||||
client, // Expose client for token counting
|
||||
|
||||
/** Non-streaming query */
|
||||
async query({ systemPrompt, messages, tools, maxTokens = 8192 }) {
|
||||
const toolDefs = tools.map(t => ({
|
||||
name: t.name,
|
||||
@ -52,6 +60,107 @@ export function createAnthropicProvider(opts = {}) {
|
||||
model: response.model,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Streaming query — real-time text output.
|
||||
* @param {Object} params - Same as query
|
||||
* @param {Object} callbacks
|
||||
* @param {Function} callbacks.onText - Called with each text delta
|
||||
* @param {Function} callbacks.onToolUse - Called when tool use starts
|
||||
* @param {Function} callbacks.onToolInput - Called with tool input deltas
|
||||
* @param {Function} callbacks.onComplete - Called with final message
|
||||
* @returns {Promise<Object>} Final response in same format as query
|
||||
*/
|
||||
async streamQuery({ systemPrompt, messages, tools, maxTokens = 8192 }, callbacks = {}) {
|
||||
const toolDefs = tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.inputSchema,
|
||||
}));
|
||||
|
||||
const content = [];
|
||||
let currentToolUse = null;
|
||||
let currentToolInput = '';
|
||||
let usage = null;
|
||||
let stopReason = null;
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
system: Array.isArray(systemPrompt) ? systemPrompt.join('\n\n') : systemPrompt,
|
||||
messages,
|
||||
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case 'content_block_start':
|
||||
if (event.content_block.type === 'text') {
|
||||
content.push({ type: 'text', text: '' });
|
||||
} else if (event.content_block.type === 'tool_use') {
|
||||
currentToolUse = {
|
||||
type: 'tool_use',
|
||||
id: event.content_block.id,
|
||||
name: event.content_block.name,
|
||||
input: {},
|
||||
};
|
||||
currentToolInput = '';
|
||||
content.push(currentToolUse);
|
||||
callbacks.onToolUse?.(event.content_block.name, event.content_block.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content_block_delta':
|
||||
if (event.delta.type === 'text_delta') {
|
||||
const lastText = content[content.length - 1];
|
||||
if (lastText && lastText.type === 'text') {
|
||||
lastText.text += event.delta.text;
|
||||
}
|
||||
callbacks.onText?.(event.delta.text);
|
||||
} else if (event.delta.type === 'input_json_delta') {
|
||||
currentToolInput += event.delta.partial_json;
|
||||
callbacks.onToolInput?.(event.delta.partial_json);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content_block_stop':
|
||||
if (currentToolUse) {
|
||||
try {
|
||||
currentToolUse.input = JSON.parse(currentToolInput || '{}');
|
||||
} catch {
|
||||
currentToolUse.input = {};
|
||||
}
|
||||
currentToolUse = null;
|
||||
currentToolInput = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_delta':
|
||||
stopReason = event.delta?.stop_reason;
|
||||
if (event.usage) {
|
||||
usage = { ...usage, ...event.usage };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_start':
|
||||
if (event.message?.usage) {
|
||||
usage = event.message.usage;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const finalMessage = await stream.finalMessage();
|
||||
|
||||
callbacks.onComplete?.(finalMessage);
|
||||
|
||||
return {
|
||||
stopReason: finalMessage.stop_reason,
|
||||
content: finalMessage.content,
|
||||
usage: finalMessage.usage,
|
||||
model: finalMessage.model,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
383
src/services/agentFork.js
Normal file
383
src/services/agentFork.js
Normal file
@ -0,0 +1,383 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Sub-Agent & Task System
|
||||
*
|
||||
* Enables forking child agents for parallel/background task execution.
|
||||
* Parent agent can spawn sub-agents, track their progress, and collect
|
||||
* results. Sub-agents run in isolated sessions with their own tool scope.
|
||||
*
|
||||
* Features:
|
||||
* - Agent forking: spawn a child agent with a specific task
|
||||
* - Task tracking: create/update/list/stop background tasks
|
||||
* - Result collection: sub-agent results flow back to parent
|
||||
* - Isolation: sub-agents can't escape their scope
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const HOME = homedir();
|
||||
const TASKS_DIR = join(HOME, 'alfred-agent', 'data', 'tasks');
|
||||
mkdirSync(TASKS_DIR, { recursive: true });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TASK SYSTEM
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Task states
|
||||
*/
|
||||
const TASK_STATES = {
|
||||
PENDING: 'pending',
|
||||
RUNNING: 'running',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
CANCELLED: 'cancelled',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new task.
|
||||
* @param {string} description - What the task should accomplish
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.parentSessionId - Parent session that created this task
|
||||
* @param {string} opts.profile - Hook profile ('commander' or 'customer')
|
||||
* @returns {Object} Task object
|
||||
*/
|
||||
export function createTask(description, opts = {}) {
|
||||
const task = {
|
||||
id: randomUUID().slice(0, 12),
|
||||
description,
|
||||
state: TASK_STATES.PENDING,
|
||||
parentSessionId: opts.parentSessionId || null,
|
||||
profile: opts.profile || 'commander',
|
||||
result: null,
|
||||
error: null,
|
||||
progress: [],
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
completed: null,
|
||||
};
|
||||
|
||||
saveTask(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task.
|
||||
* @param {string} taskId
|
||||
* @param {Object} updates
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function updateTask(taskId, updates) {
|
||||
const task = loadTask(taskId);
|
||||
if (!task) return null;
|
||||
|
||||
Object.assign(task, updates, { updated: new Date().toISOString() });
|
||||
|
||||
if (updates.state === TASK_STATES.COMPLETED || updates.state === TASK_STATES.FAILED) {
|
||||
task.completed = new Date().toISOString();
|
||||
}
|
||||
|
||||
saveTask(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add progress note to a task.
|
||||
* @param {string} taskId
|
||||
* @param {string} note
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function addTaskProgress(taskId, note) {
|
||||
const task = loadTask(taskId);
|
||||
if (!task) return null;
|
||||
|
||||
task.progress.push({
|
||||
note,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
task.updated = new Date().toISOString();
|
||||
|
||||
saveTask(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a task by ID.
|
||||
* @param {string} taskId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function loadTask(taskId) {
|
||||
const filepath = join(TASKS_DIR, `${taskId}.json`);
|
||||
if (!existsSync(filepath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filepath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a task to disk.
|
||||
* @param {Object} task
|
||||
*/
|
||||
function saveTask(task) {
|
||||
writeFileSync(
|
||||
join(TASKS_DIR, `${task.id}.json`),
|
||||
JSON.stringify(task, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks (optionally filtered by state).
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.state - Filter by state
|
||||
* @param {string} opts.parentSessionId - Filter by parent session
|
||||
* @param {number} opts.limit - Max results
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function listTasks(opts = {}) {
|
||||
if (!existsSync(TASKS_DIR)) return [];
|
||||
|
||||
const files = readdirSync(TASKS_DIR).filter(f => f.endsWith('.json'));
|
||||
let tasks = files.map(f => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(TASKS_DIR, f), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
if (opts.state) {
|
||||
tasks = tasks.filter(t => t.state === opts.state);
|
||||
}
|
||||
if (opts.parentSessionId) {
|
||||
tasks = tasks.filter(t => t.parentSessionId === opts.parentSessionId);
|
||||
}
|
||||
|
||||
tasks.sort((a, b) => new Date(b.updated) - new Date(a.updated));
|
||||
|
||||
if (opts.limit) {
|
||||
tasks = tasks.slice(0, opts.limit);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SUB-AGENT FORKING
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Fork a sub-agent to execute a task.
|
||||
* The sub-agent runs a single multi-turn conversation to accomplish the task,
|
||||
* then returns the result.
|
||||
*
|
||||
* @param {Object} provider - AI provider for the sub-agent
|
||||
* @param {string} taskDescription - What the sub-agent should do
|
||||
* @param {Object} opts
|
||||
* @param {Object} opts.parentAgent - Parent agent reference
|
||||
* @param {string} opts.profile - Hook profile
|
||||
* @param {Array} opts.tools - Tools available to sub-agent
|
||||
* @param {number} opts.maxRounds - Max tool rounds for sub-agent (default: 10)
|
||||
* @param {Function} opts.onProgress - Progress callback
|
||||
* @returns {Promise<Object>} { result, toolCalls, tokensUsed }
|
||||
*/
|
||||
export async function forkAgent(provider, taskDescription, opts = {}) {
|
||||
const maxRounds = opts.maxRounds || 10;
|
||||
const onProgress = opts.onProgress || (() => {});
|
||||
|
||||
// Import tools dynamically to avoid circular deps
|
||||
const { getTools, executeTool } = await import('../tools.js');
|
||||
const tools = opts.tools || getTools();
|
||||
|
||||
const systemPrompt = `You are a sub-agent of Alfred, executing a specific task. Complete the task efficiently and return a clear result.
|
||||
|
||||
Task: ${taskDescription}
|
||||
|
||||
Rules:
|
||||
- Focus only on the assigned task
|
||||
- Use tools as needed to complete the task
|
||||
- When done, provide a clear summary of what was accomplished
|
||||
- If you cannot complete the task, explain what went wrong
|
||||
- Do NOT ask questions — make reasonable decisions and proceed
|
||||
- Limit your work to what was specifically asked`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'user', content: taskDescription },
|
||||
];
|
||||
|
||||
const toolCalls = [];
|
||||
let tokensUsed = 0;
|
||||
let resultText = '';
|
||||
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
onProgress({ round, maxRounds, status: 'querying' });
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await provider.query({
|
||||
systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
} catch (err) {
|
||||
return { result: `Sub-agent error: ${err.message}`, toolCalls, tokensUsed, error: true };
|
||||
}
|
||||
|
||||
if (response.usage) {
|
||||
tokensUsed += (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0);
|
||||
}
|
||||
|
||||
// Process response
|
||||
const assistantContent = response.content;
|
||||
const toolUseBlocks = [];
|
||||
|
||||
for (const block of assistantContent) {
|
||||
if (block.type === 'text') {
|
||||
resultText += block.text;
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolUseBlocks.push(block);
|
||||
toolCalls.push({ name: block.name, input: block.input });
|
||||
onProgress({ round, tool: block.name, status: 'executing' });
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({ role: 'assistant', content: assistantContent });
|
||||
|
||||
// Done if no tool calls
|
||||
if (response.stopReason !== 'tool_use' || toolUseBlocks.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute tools
|
||||
const toolResults = [];
|
||||
for (const tc of toolUseBlocks) {
|
||||
const result = await executeTool(tc.name, tc.input);
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tc.id,
|
||||
content: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
|
||||
return { result: resultText, toolCalls, tokensUsed, error: false };
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TOOL REGISTRATIONS — Agent and Task tools
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get the agent/task tool definitions for registration.
|
||||
* @param {Object} provider - AI provider for sub-agent forking
|
||||
* @param {string} sessionId - Current session ID
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getAgentTaskTools(provider, sessionId) {
|
||||
return [
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Fork a sub-agent to handle a complex sub-task autonomously. The sub-agent runs its own tool loop and returns a result. Use this for tasks that require multiple steps and would clutter the main conversation. The sub-agent has access to the same tools.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: { type: 'string', description: 'Detailed description of what the sub-agent should accomplish' },
|
||||
maxRounds: { type: 'number', description: 'Max tool rounds (default: 10)' },
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
async execute({ task, maxRounds }) {
|
||||
const result = await forkAgent(provider, task, {
|
||||
maxRounds: maxRounds || 10,
|
||||
});
|
||||
return {
|
||||
result: result.result?.slice(0, 10000) || 'No result',
|
||||
toolCalls: result.toolCalls.length,
|
||||
tokensUsed: result.tokensUsed,
|
||||
error: result.error || false,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'task_create',
|
||||
description: 'Create a tracked task for later execution or progress tracking.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string', description: 'Task description' },
|
||||
},
|
||||
required: ['description'],
|
||||
},
|
||||
async execute({ description }) {
|
||||
const task = createTask(description, { parentSessionId: sessionId });
|
||||
return { taskId: task.id, state: task.state };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'task_update',
|
||||
description: 'Update a task\'s state or add a progress note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID' },
|
||||
state: { type: 'string', description: 'New state: pending, running, completed, failed, cancelled' },
|
||||
note: { type: 'string', description: 'Progress note to add' },
|
||||
result: { type: 'string', description: 'Task result (for completed state)' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
async execute({ taskId, state, note, result }) {
|
||||
if (note) {
|
||||
addTaskProgress(taskId, note);
|
||||
}
|
||||
if (state || result) {
|
||||
const updates = {};
|
||||
if (state) updates.state = state;
|
||||
if (result) updates.result = result;
|
||||
updateTask(taskId, updates);
|
||||
}
|
||||
const task = loadTask(taskId);
|
||||
return task || { error: 'Task not found' };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'task_list',
|
||||
description: 'List tracked tasks, optionally filtered by state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
state: { type: 'string', description: 'Filter: pending, running, completed, failed, cancelled' },
|
||||
limit: { type: 'number', description: 'Max results (default: 20)' },
|
||||
},
|
||||
},
|
||||
async execute({ state, limit }) {
|
||||
const tasks = listTasks({ state, limit: limit || 20 });
|
||||
return {
|
||||
tasks: tasks.map(t => ({
|
||||
id: t.id,
|
||||
description: t.description.slice(0, 200),
|
||||
state: t.state,
|
||||
progress: t.progress.length,
|
||||
updated: t.updated,
|
||||
})),
|
||||
total: tasks.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
694
src/services/compact.js
Normal file
694
src/services/compact.js
Normal file
@ -0,0 +1,694 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — 4-Tier Compaction Engine
|
||||
*
|
||||
* Keeps conversations productive across any length by intelligently
|
||||
* managing context window usage.
|
||||
*
|
||||
* Tier 1: MICRO-COMPACT
|
||||
* - Runs between turns, zero API cost
|
||||
* - Replaces cached tool results (file reads, greps) with short summaries
|
||||
* - Preserves tool_use/tool_result structure for API validity
|
||||
*
|
||||
* Tier 2: AUTO-COMPACT
|
||||
* - Fires when token usage exceeds threshold (~87% of context window)
|
||||
* - Sends full conversation to AI for structured summarization
|
||||
* - Replaces all pre-boundary messages with summary + key file attachments
|
||||
*
|
||||
* Tier 3: SESSION-MEMORY COMPACT
|
||||
* - Extracts durable session memories to persistent storage
|
||||
* - Keeps key facts alive across compactions and sessions
|
||||
* - Runs as part of auto-compact flow
|
||||
*
|
||||
* Tier 4: POST-COMPACT CLEANUP
|
||||
* - Re-injects up to 5 most-read files (capped at 5K tokens each)
|
||||
* - Re-injects active skills, plans, and tool deltas
|
||||
* - Restores critical context the model needs to continue work
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
estimateConversationTokens,
|
||||
estimateMessageTokens,
|
||||
estimateForFileType,
|
||||
getAutoCompactThreshold,
|
||||
getEffectiveContextWindow,
|
||||
MAX_FILES_TO_RESTORE,
|
||||
MAX_TOKENS_PER_FILE,
|
||||
} from './tokenEstimation.js';
|
||||
import {
|
||||
createCompactBoundaryMessage,
|
||||
createAttachmentMessage,
|
||||
createTombstoneMessage,
|
||||
createToolSummaryMessage,
|
||||
isCompactBoundary,
|
||||
getAssistantText,
|
||||
getToolUseBlocks,
|
||||
} from './messages.js';
|
||||
|
||||
const HOME = homedir();
|
||||
const DATA_DIR = join(HOME, 'alfred-agent', 'data');
|
||||
const TRANSCRIPTS_DIR = join(DATA_DIR, 'transcripts');
|
||||
const MEMORIES_DIR = join(DATA_DIR, 'memories');
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
|
||||
mkdirSync(MEMORIES_DIR, { recursive: true });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TIER 1: MICRO-COMPACT — Zero-cost tool result collapsing
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Tools whose results can be safely summarized between turns.
|
||||
* These are "read-only" tools where the model already saw and reacted to the output.
|
||||
*/
|
||||
const MICRO_COMPACTABLE_TOOLS = new Set([
|
||||
'read_file', 'grep', 'glob', 'list_dir', 'web_fetch',
|
||||
'mcp_list', 'mcp_call', 'pm2_status', 'memory_recall',
|
||||
'db_query',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Minimum age (in messages) before a tool result is eligible for micro-compact.
|
||||
* Don't compact results the model is still actively using.
|
||||
*/
|
||||
const MICRO_COMPACT_MIN_AGE = 6;
|
||||
|
||||
/**
|
||||
* Decay-window thresholds (Omahon pattern port).
|
||||
* Messages older than DECAY_AGGRESSIVE_AGE get compacted even if they're
|
||||
* non-standard tools. Messages in the "warm" window (between MIN_AGE and
|
||||
* AGGRESSIVE_AGE) only compact standard cacheable tools.
|
||||
* The SKELETON threshold turns very old messages into single-line tombstones.
|
||||
*/
|
||||
const DECAY_AGGRESSIVE_AGE = 20; // messages — expand compactable set
|
||||
const DECAY_SKELETON_AGE = 40; // messages — reduce to skeleton summaries
|
||||
|
||||
/**
|
||||
* Additional tools that become compactable once a message is old enough
|
||||
* (past DECAY_AGGRESSIVE_AGE). These are tools whose results were important
|
||||
* when fresh but lose value over conversation distance.
|
||||
*/
|
||||
const DECAY_COMPACTABLE_TOOLS = new Set([
|
||||
...MICRO_COMPACTABLE_TOOLS,
|
||||
'bash', 'write_file', 'edit_file', 'git_diff', 'git_log',
|
||||
'search_files', 'find_files', 'pm2_logs',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Run micro-compaction on a message array.
|
||||
* Replaces old tool results with abbreviated summaries to free token space.
|
||||
* Uses Omahon-style decay windows: recent messages are protected, warm messages
|
||||
* compact standard tools, old messages compact aggressively, very old messages
|
||||
* become skeleton tombstones.
|
||||
* Returns a new array (does not mutate the original).
|
||||
*
|
||||
* @param {Array} messages - The conversation messages
|
||||
* @returns {{ messages: Array, tokensFreed: number }}
|
||||
*/
|
||||
export function microCompact(messages) {
|
||||
if (messages.length < MICRO_COMPACT_MIN_AGE + 2) {
|
||||
return { messages, tokensFreed: 0 };
|
||||
}
|
||||
|
||||
let tokensFreed = 0;
|
||||
const safeZone = messages.length - MICRO_COMPACT_MIN_AGE;
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
// Only compact tool_result content blocks in user messages
|
||||
if (i < safeZone && msg.role === 'user' && Array.isArray(msg.content)) {
|
||||
const hasToolResults = msg.content.some(b => b.type === 'tool_result');
|
||||
const messageAge = messages.length - i; // distance from end
|
||||
|
||||
if (hasToolResults) {
|
||||
// Find the preceding assistant message to match tool names
|
||||
const prevAssistant = i > 0 ? messages[i - 1] : null;
|
||||
const toolUseMap = new Map();
|
||||
if (prevAssistant && Array.isArray(prevAssistant.content)) {
|
||||
for (const block of prevAssistant.content) {
|
||||
if (block.type === 'tool_use') {
|
||||
toolUseMap.set(block.id, block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = msg.content.map(block => {
|
||||
if (block.type !== 'tool_result') return block;
|
||||
|
||||
const toolName = toolUseMap.get(block.tool_use_id) || 'unknown';
|
||||
|
||||
// Decay-window logic: which tools are compactable depends on age
|
||||
const isStandardCompactable = MICRO_COMPACTABLE_TOOLS.has(toolName);
|
||||
const isDecayCompactable = DECAY_COMPACTABLE_TOOLS.has(toolName);
|
||||
const inAggressiveWindow = messageAge >= DECAY_AGGRESSIVE_AGE;
|
||||
const inSkeletonWindow = messageAge >= DECAY_SKELETON_AGE;
|
||||
|
||||
// Skip if not compactable at this age
|
||||
if (!isStandardCompactable && !(inAggressiveWindow && isDecayCompactable)) return block;
|
||||
|
||||
const originalContent = typeof block.content === 'string'
|
||||
? block.content
|
||||
: JSON.stringify(block.content || '');
|
||||
|
||||
// Only compact if the result is substantial (lower bar for skeleton window)
|
||||
const minLength = inSkeletonWindow ? 50 : 200;
|
||||
if (originalContent.length < minLength) return block;
|
||||
|
||||
const beforeTokens = estimateMessageTokens({ content: originalContent });
|
||||
|
||||
// Skeleton window: ultra-brief tombstone
|
||||
const summary = inSkeletonWindow
|
||||
? `[${toolName}: ${originalContent.length} chars, aged out]`
|
||||
: summarizeToolResult(toolName, originalContent);
|
||||
|
||||
const afterTokens = estimateMessageTokens({ content: summary });
|
||||
tokensFreed += Math.max(0, beforeTokens - afterTokens);
|
||||
|
||||
return {
|
||||
...block,
|
||||
content: summary,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({ ...msg, content: newContent });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
return { messages: result, tokensFreed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brief summary of a tool result.
|
||||
* @param {string} toolName
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function summarizeToolResult(toolName, content) {
|
||||
const maxPreview = 150;
|
||||
|
||||
switch (toolName) {
|
||||
case 'read_file': {
|
||||
const lineCount = (content.match(/\n/g) || []).length + 1;
|
||||
const preview = content.slice(0, maxPreview).replace(/\n/g, '\\n');
|
||||
return `[Cached: read ${lineCount} lines] ${preview}...`;
|
||||
}
|
||||
case 'grep': {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const count = parsed.count || parsed.matches?.length || 0;
|
||||
return `[Cached: ${count} matches found]`;
|
||||
} catch {
|
||||
const matchCount = (content.match(/\n/g) || []).length;
|
||||
return `[Cached: ~${matchCount} grep matches]`;
|
||||
}
|
||||
}
|
||||
case 'glob': {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const count = parsed.count || parsed.files?.length || 0;
|
||||
return `[Cached: ${count} files matched]`;
|
||||
} catch {
|
||||
return `[Cached: glob results]`;
|
||||
}
|
||||
}
|
||||
case 'list_dir': {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const count = parsed.count || parsed.entries?.length || 0;
|
||||
return `[Cached: ${count} entries in directory]`;
|
||||
} catch {
|
||||
return `[Cached: directory listing]`;
|
||||
}
|
||||
}
|
||||
case 'web_fetch': {
|
||||
const preview = content.slice(0, maxPreview);
|
||||
return `[Cached: web page content] ${preview}...`;
|
||||
}
|
||||
case 'db_query': {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const count = parsed.count || parsed.rows?.length || 0;
|
||||
return `[Cached: ${count} rows returned]`;
|
||||
} catch {
|
||||
return `[Cached: query results]`;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return `[Cached: ${toolName} result (${content.length} chars)]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TIER 2: AUTO-COMPACT — AI-driven conversation summarization
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The compact prompt — instructs the model to create a structured summary.
|
||||
* Uses analysis/summary XML pattern for higher quality summaries.
|
||||
*/
|
||||
const COMPACT_PROMPT = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
||||
Tool calls will be REJECTED and will waste your only turn — you will fail the task.
|
||||
|
||||
Your task is to create a detailed summary of the conversation so far, capturing technical details, code patterns, and decisions essential for continuing work without losing context.
|
||||
|
||||
Before your summary, wrap analysis in <analysis> tags to organize your thoughts:
|
||||
|
||||
1. Chronologically analyze each message. For each, identify:
|
||||
- The user's explicit requests and intents
|
||||
- Your approach to addressing them
|
||||
- Key decisions, technical concepts, code patterns
|
||||
- Specific details: file names, code snippets, function signatures, file edits
|
||||
- Errors encountered and how they were fixed
|
||||
- User feedback, especially corrections
|
||||
|
||||
Your summary should include these sections:
|
||||
|
||||
<summary>
|
||||
1. Primary Request and Intent: The user's explicit requests in detail
|
||||
2. Key Technical Concepts: Technologies, frameworks, patterns discussed
|
||||
3. Files and Code: Files examined, modified, or created with snippets and context
|
||||
4. Errors and Fixes: Errors encountered and how they were resolved
|
||||
5. Problem Solving: Problems solved and ongoing troubleshooting
|
||||
6. User Messages: ALL non-tool-result user messages (critical for context)
|
||||
7. Pending Tasks: Outstanding tasks explicitly requested
|
||||
8. Current Work: Precisely what was being worked on most recently
|
||||
9. Next Step: The immediate next step aligned with user's most recent request
|
||||
</summary>
|
||||
|
||||
REMINDER: Respond with plain text ONLY — an <analysis> block followed by a <summary> block.
|
||||
Do NOT call any tools.`;
|
||||
|
||||
/**
|
||||
* Check if auto-compaction should happen.
|
||||
* @param {Array} messages
|
||||
* @param {string} model
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldAutoCompact(messages, model) {
|
||||
const tokenCount = estimateConversationTokens(messages);
|
||||
const threshold = getAutoCompactThreshold(model);
|
||||
return tokenCount >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-compaction: summarize the conversation using the AI model,
|
||||
* then rebuild with summary + key attachments.
|
||||
*
|
||||
* @param {Array} messages - Current conversation messages
|
||||
* @param {Object} provider - AI provider for summarization
|
||||
* @param {string} model - Model name for threshold calculation
|
||||
* @param {Object} opts - Options
|
||||
* @param {Object} opts.fileTracker - Context tracker for file restoration
|
||||
* @param {Function} opts.onProgress - Progress callback
|
||||
* @returns {Promise<Object>} { messages, summary, tokensFreed, transcriptPath }
|
||||
*/
|
||||
export async function autoCompact(messages, provider, model, opts = {}) {
|
||||
const preCompactTokens = estimateConversationTokens(messages);
|
||||
const onProgress = opts.onProgress || (() => {});
|
||||
|
||||
onProgress({ type: 'compact_start', preCompactTokens });
|
||||
|
||||
// 1. Save transcript before compacting
|
||||
const transcriptPath = saveTranscript(messages, opts.sessionId);
|
||||
|
||||
// 2. Try session memory extraction first (Tier 3)
|
||||
const sessionMemories = extractSessionMemories(messages);
|
||||
if (sessionMemories.length > 0) {
|
||||
saveSessionMemories(sessionMemories, opts.sessionId);
|
||||
onProgress({ type: 'session_memory', count: sessionMemories.length });
|
||||
}
|
||||
|
||||
// 3. Get AI summary of the conversation
|
||||
onProgress({ type: 'summarizing' });
|
||||
|
||||
let summary;
|
||||
try {
|
||||
const summaryResponse = await provider.query({
|
||||
systemPrompt: 'You are a conversation summarizer. Create a detailed, structured summary.',
|
||||
messages: [
|
||||
...messages.map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: COMPACT_PROMPT },
|
||||
],
|
||||
tools: [], // No tools during compaction
|
||||
maxTokens: 16384,
|
||||
});
|
||||
|
||||
// Extract text from response
|
||||
summary = summaryResponse.content
|
||||
.filter(b => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('\n');
|
||||
} catch (err) {
|
||||
// Fallback to naive compaction if AI summarization fails
|
||||
onProgress({ type: 'fallback', reason: err.message });
|
||||
summary = naiveSummary(messages);
|
||||
}
|
||||
|
||||
// 4. Format the summary (strip analysis block, keep summary)
|
||||
summary = formatCompactSummary(summary);
|
||||
|
||||
// 5. Build post-compact messages
|
||||
const postCompactMessages = buildPostCompactMessages(
|
||||
summary,
|
||||
messages,
|
||||
transcriptPath,
|
||||
opts.fileTracker,
|
||||
opts.activeSkills,
|
||||
);
|
||||
|
||||
const postCompactTokens = estimateConversationTokens(postCompactMessages);
|
||||
|
||||
onProgress({
|
||||
type: 'compact_done',
|
||||
preCompactTokens,
|
||||
postCompactTokens,
|
||||
tokensFreed: preCompactTokens - postCompactTokens,
|
||||
messagesRemoved: messages.length - postCompactMessages.length,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: postCompactMessages,
|
||||
summary,
|
||||
tokensFreed: preCompactTokens - postCompactTokens,
|
||||
transcriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the post-compact message array.
|
||||
* Compact boundary + summary + restored files + skill attachments.
|
||||
*/
|
||||
function buildPostCompactMessages(summary, originalMessages, transcriptPath, fileTracker, activeSkills) {
|
||||
const result = [];
|
||||
|
||||
// 1. Compact boundary marker with summary
|
||||
const userSummary = `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion.
|
||||
|
||||
${summary}
|
||||
|
||||
If you need specific details from before compaction (exact code snippets, error messages), read the full transcript at: ${transcriptPath}
|
||||
|
||||
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening. Pick up the last task as if the break never happened.`;
|
||||
|
||||
result.push(createCompactBoundaryMessage('auto', 0, userSummary));
|
||||
|
||||
// 2. Assistant acknowledgement (keeps alternation valid)
|
||||
result.push({
|
||||
id: require('crypto').randomUUID(),
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Understood. I have the full context and will continue where we left off.' }],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 3. Restore most-accessed files (Tier 4: post-compact cleanup)
|
||||
if (fileTracker) {
|
||||
const topFiles = fileTracker.getTopFiles(MAX_FILES_TO_RESTORE);
|
||||
for (const filePath of topFiles) {
|
||||
try {
|
||||
if (!existsSync(filePath)) continue;
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const ext = extname(filePath).replace('.', '');
|
||||
const fileTokens = estimateForFileType(content, ext);
|
||||
|
||||
if (fileTokens > MAX_TOKENS_PER_FILE) {
|
||||
// Truncate to fit budget
|
||||
const ratio = MAX_TOKENS_PER_FILE / fileTokens;
|
||||
const truncated = content.slice(0, Math.floor(content.length * ratio));
|
||||
result.push(createAttachmentMessage(filePath, truncated + '\n[...truncated]', 'file'));
|
||||
} else {
|
||||
result.push(createAttachmentMessage(filePath, content, 'file'));
|
||||
}
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Restore active skills
|
||||
if (activeSkills && activeSkills.length > 0) {
|
||||
for (const skill of activeSkills.slice(0, 5)) {
|
||||
result.push(createAttachmentMessage(
|
||||
`Skill: ${skill.name}`,
|
||||
skill.content,
|
||||
'skill',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TIER 3: SESSION-MEMORY COMPACT — Durable memory extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Extract durable session memories from conversation messages.
|
||||
* Looks for patterns like decisions, discoveries, preferences, and facts.
|
||||
* @param {Array} messages
|
||||
* @returns {Array<{key: string, content: string}>}
|
||||
*/
|
||||
function extractSessionMemories(messages) {
|
||||
const memories = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: (Array.isArray(msg.content)
|
||||
? msg.content.filter(b => b.type === 'text').map(b => b.text).join('\n')
|
||||
: '');
|
||||
|
||||
if (!text || text.length < 50) continue;
|
||||
|
||||
// Look for memory-worthy patterns in assistant responses
|
||||
if (msg.role === 'assistant') {
|
||||
// File path discoveries
|
||||
const filePaths = text.match(/(?:found|located|file|path).*?['"` ]([/~][a-zA-Z0-9_/.-]+)/gi);
|
||||
if (filePaths) {
|
||||
for (const match of filePaths.slice(0, 3)) {
|
||||
memories.push({ key: 'file-discoveries', content: match.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
// Error resolution patterns
|
||||
if (/(?:fixed|resolved|root cause|the issue was|bug was)/i.test(text)) {
|
||||
const errorSection = text.slice(0, 500);
|
||||
memories.push({ key: 'error-resolutions', content: errorSection });
|
||||
}
|
||||
|
||||
// Architecture decisions
|
||||
if (/(?:decided to|approach:|design:|architecture:|pattern:)/i.test(text)) {
|
||||
const decisionSection = text.slice(0, 500);
|
||||
memories.push({ key: 'architecture-decisions', content: decisionSection });
|
||||
}
|
||||
}
|
||||
|
||||
// User preferences/corrections
|
||||
if (msg.role === 'user' && typeof msg.content === 'string') {
|
||||
if (/(?:don't|never|always|prefer|instead|actually|no,|wrong)/i.test(msg.content)) {
|
||||
memories.push({ key: 'user-preferences', content: msg.content.slice(0, 300) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save extracted session memories to persistent storage.
|
||||
* @param {Array} memories
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
function saveSessionMemories(memories, sessionId) {
|
||||
if (memories.length === 0) return;
|
||||
|
||||
const grouped = {};
|
||||
for (const m of memories) {
|
||||
if (!grouped[m.key]) grouped[m.key] = [];
|
||||
grouped[m.key].push(m.content);
|
||||
}
|
||||
|
||||
for (const [key, entries] of Object.entries(grouped)) {
|
||||
const filename = `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.md`;
|
||||
const filepath = join(MEMORIES_DIR, filename);
|
||||
|
||||
const header = existsSync(filepath)
|
||||
? readFileSync(filepath, 'utf8')
|
||||
: `# Memory: ${key}\n`;
|
||||
|
||||
const newEntries = entries.map(e =>
|
||||
`\n## ${new Date().toISOString()}${sessionId ? ` (session: ${sessionId})` : ''}\n${e}\n`
|
||||
).join('');
|
||||
|
||||
writeFileSync(filepath, header + newEntries, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Save full transcript before compacting (for recovery).
|
||||
* @param {Array} messages
|
||||
* @param {string} sessionId
|
||||
* @returns {string} transcript file path
|
||||
*/
|
||||
function saveTranscript(messages, sessionId) {
|
||||
const id = sessionId || `compact-${Date.now()}`;
|
||||
const filepath = join(TRANSCRIPTS_DIR, `${id}.jsonl`);
|
||||
|
||||
const lines = messages.map(m => JSON.stringify({
|
||||
role: m.role,
|
||||
type: m.type,
|
||||
content: typeof m.content === 'string' ? m.content.slice(0, 10000) : m.content,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
|
||||
writeFileSync(filepath, lines.join('\n'), 'utf8');
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format compact summary — strip analysis block, keep summary.
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatCompactSummary(raw) {
|
||||
if (!raw) return '';
|
||||
|
||||
// Strip analysis section (drafting scratchpad)
|
||||
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/i, '');
|
||||
|
||||
// Extract summary section
|
||||
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i);
|
||||
if (summaryMatch) {
|
||||
formatted = summaryMatch[1].trim();
|
||||
}
|
||||
|
||||
// Clean up whitespace
|
||||
formatted = formatted.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive summary fallback (when AI summarization fails).
|
||||
* @param {Array} messages
|
||||
* @returns {string}
|
||||
*/
|
||||
function naiveSummary(messages) {
|
||||
const parts = [];
|
||||
let userCount = 0;
|
||||
let assistantCount = 0;
|
||||
let toolCalls = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'user' && typeof msg.content === 'string') {
|
||||
userCount++;
|
||||
parts.push(`User: ${msg.content.slice(0, 200)}`);
|
||||
} else if (msg.role === 'assistant') {
|
||||
assistantCount++;
|
||||
const text = getAssistantText(msg);
|
||||
if (text) parts.push(`Assistant: ${text.slice(0, 200)}`);
|
||||
toolCalls += getToolUseBlocks(msg).length;
|
||||
}
|
||||
}
|
||||
|
||||
return `[Fallback summary — ${messages.length} messages, ${userCount} user, ${assistantCount} assistant, ${toolCalls} tool calls]
|
||||
|
||||
Previous conversation:
|
||||
${parts.join('\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max consecutive auto-compact failures before circuit breaker trips.
|
||||
*/
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
|
||||
/**
|
||||
* Auto-compact tracking state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createCompactTracking() {
|
||||
return {
|
||||
compacted: false,
|
||||
turnCounter: 0,
|
||||
consecutiveFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full auto-compact-if-needed flow (called from agent loop).
|
||||
* Handles micro-compact first, then auto-compact if still needed.
|
||||
*
|
||||
* @param {Array} messages
|
||||
* @param {Object} provider
|
||||
* @param {string} model
|
||||
* @param {Object} tracking - Mutable tracking state
|
||||
* @param {Object} opts
|
||||
* @returns {Promise<{ messages: Array, wasCompacted: boolean, tier: string|null }>}
|
||||
*/
|
||||
export async function compactIfNeeded(messages, provider, model, tracking, opts = {}) {
|
||||
// Circuit breaker
|
||||
if (tracking.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
return { messages, wasCompacted: false, tier: null };
|
||||
}
|
||||
|
||||
// Tier 1: Always try micro-compact first (free)
|
||||
const { messages: microMessages, tokensFreed: microFreed } = microCompact(messages);
|
||||
if (microFreed > 0) {
|
||||
messages = microMessages;
|
||||
opts.onProgress?.({ type: 'micro_compact', tokensFreed: microFreed });
|
||||
}
|
||||
|
||||
// Check if auto-compact is needed after micro-compact
|
||||
if (!shouldAutoCompact(messages, model)) {
|
||||
return { messages, wasCompacted: microFreed > 0, tier: microFreed > 0 ? 'micro' : null };
|
||||
}
|
||||
|
||||
// Tier 2: Auto-compact
|
||||
try {
|
||||
tracking.turnCounter++;
|
||||
const result = await autoCompact(messages, provider, model, {
|
||||
sessionId: opts.sessionId,
|
||||
fileTracker: opts.fileTracker,
|
||||
activeSkills: opts.activeSkills,
|
||||
onProgress: opts.onProgress,
|
||||
});
|
||||
|
||||
tracking.compacted = true;
|
||||
tracking.consecutiveFailures = 0;
|
||||
|
||||
return {
|
||||
messages: result.messages,
|
||||
wasCompacted: true,
|
||||
tier: 'auto',
|
||||
summary: result.summary,
|
||||
transcriptPath: result.transcriptPath,
|
||||
tokensFreed: result.tokensFreed,
|
||||
};
|
||||
} catch (err) {
|
||||
tracking.consecutiveFailures++;
|
||||
opts.onProgress?.({
|
||||
type: 'compact_error',
|
||||
error: err.message,
|
||||
failures: tracking.consecutiveFailures,
|
||||
});
|
||||
return { messages, wasCompacted: false, tier: null };
|
||||
}
|
||||
}
|
||||
223
src/services/contextTracker.js
Normal file
223
src/services/contextTracker.js
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Context Tracker
|
||||
*
|
||||
* Tracks files read, files written, git state, and recent edits
|
||||
* so the compaction engine can restore the most important context
|
||||
* after compacting.
|
||||
*
|
||||
* Features:
|
||||
* - File access frequency tracking (most-read files get restored first)
|
||||
* - File modification tracking (know what was changed this session)
|
||||
* - Git status snapshot (branch, dirty files, recent commits)
|
||||
* - Working directory tracking
|
||||
* - Session-level delta tracking (what changed since last compact)
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { resolve, relative } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Create a new context tracker.
|
||||
* @param {string} workspaceRoot - Root directory for the workspace
|
||||
* @returns {Object} Context tracker instance
|
||||
*/
|
||||
export function createContextTracker(workspaceRoot) {
|
||||
const root = workspaceRoot || homedir();
|
||||
|
||||
// File access tracking: path → { reads, writes, lastAccess }
|
||||
const fileAccess = new Map();
|
||||
|
||||
// Files modified this session
|
||||
const modifiedFiles = new Set();
|
||||
|
||||
// Current working directory
|
||||
let cwd = root;
|
||||
|
||||
// Errors encountered this session
|
||||
const errors = [];
|
||||
|
||||
// Key decisions/facts discovered
|
||||
const discoveries = [];
|
||||
|
||||
return {
|
||||
/**
|
||||
* Record a file read.
|
||||
* @param {string} filePath
|
||||
*/
|
||||
trackRead(filePath) {
|
||||
const resolved = resolve(root, filePath);
|
||||
const entry = fileAccess.get(resolved) || { reads: 0, writes: 0, lastAccess: null };
|
||||
entry.reads++;
|
||||
entry.lastAccess = Date.now();
|
||||
fileAccess.set(resolved, entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a file write/edit.
|
||||
* @param {string} filePath
|
||||
*/
|
||||
trackWrite(filePath) {
|
||||
const resolved = resolve(root, filePath);
|
||||
const entry = fileAccess.get(resolved) || { reads: 0, writes: 0, lastAccess: null };
|
||||
entry.writes++;
|
||||
entry.lastAccess = Date.now();
|
||||
fileAccess.set(resolved, entry);
|
||||
modifiedFiles.add(resolved);
|
||||
},
|
||||
|
||||
/**
|
||||
* Track a bash command (for cwd changes).
|
||||
* @param {string} command
|
||||
* @param {string} cmdCwd - Working directory used
|
||||
*/
|
||||
trackCommand(command, cmdCwd) {
|
||||
if (cmdCwd) cwd = cmdCwd;
|
||||
// Track cd commands
|
||||
const cdMatch = command.match(/^\s*cd\s+(.+)/);
|
||||
if (cdMatch) {
|
||||
cwd = resolve(cwd, cdMatch[1].trim());
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Record an error.
|
||||
* @param {string} error
|
||||
* @param {string} context - What was happening when the error occurred
|
||||
*/
|
||||
trackError(error, context) {
|
||||
errors.push({
|
||||
error: typeof error === 'string' ? error : error.message,
|
||||
context,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a discovery/decision.
|
||||
* @param {string} fact
|
||||
*/
|
||||
trackDiscovery(fact) {
|
||||
discoveries.push({ fact, timestamp: Date.now() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the top N most-accessed files (by read+write count).
|
||||
* Prioritizes files that exist and were recently accessed.
|
||||
* @param {number} n
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getTopFiles(n = 5) {
|
||||
return Array.from(fileAccess.entries())
|
||||
.filter(([path]) => existsSync(path))
|
||||
.sort(([, a], [, b]) => {
|
||||
// Score: reads + 2*writes (writes are more important)
|
||||
const scoreA = a.reads + a.writes * 2;
|
||||
const scoreB = b.reads + b.writes * 2;
|
||||
if (scoreA !== scoreB) return scoreB - scoreA;
|
||||
return (b.lastAccess || 0) - (a.lastAccess || 0);
|
||||
})
|
||||
.slice(0, n)
|
||||
.map(([path]) => path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get modified files this session.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getModifiedFiles() {
|
||||
return Array.from(modifiedFiles);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get git status snapshot.
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getGitStatus() {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
|
||||
cwd: root, encoding: 'utf8', timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
const status = execSync('git status --porcelain 2>/dev/null | head -20', {
|
||||
cwd: root, encoding: 'utf8', timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
const recentCommits = execSync('git log --oneline -5 2>/dev/null', {
|
||||
cwd: root, encoding: 'utf8', timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
return {
|
||||
branch,
|
||||
dirtyFiles: status ? status.split('\n').length : 0,
|
||||
status: status || '(clean)',
|
||||
recentCommits: recentCommits || '(no commits)',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get full context snapshot for compact restoration.
|
||||
* @returns {Object}
|
||||
*/
|
||||
getSnapshot() {
|
||||
return {
|
||||
cwd,
|
||||
topFiles: this.getTopFiles(10),
|
||||
modifiedFiles: this.getModifiedFiles(),
|
||||
git: this.getGitStatus(),
|
||||
errors: errors.slice(-10),
|
||||
discoveries: discoveries.slice(-20),
|
||||
fileAccessCount: fileAccess.size,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a context summary string for embedding in compact output.
|
||||
* @returns {string}
|
||||
*/
|
||||
toContextString() {
|
||||
const snap = this.getSnapshot();
|
||||
const parts = [`Working directory: ${snap.cwd}`];
|
||||
|
||||
if (snap.modifiedFiles.length > 0) {
|
||||
parts.push(`Files modified this session:\n${snap.modifiedFiles.map(f => ` - ${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (snap.git) {
|
||||
parts.push(`Git: branch=${snap.git.branch}, ${snap.git.dirtyFiles} dirty files`);
|
||||
}
|
||||
|
||||
if (snap.errors.length > 0) {
|
||||
parts.push(`Recent errors:\n${snap.errors.slice(-5).map(e => ` - ${e.error}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (snap.discoveries.length > 0) {
|
||||
parts.push(`Key discoveries:\n${snap.discoveries.slice(-10).map(d => ` - ${d.fact}`).join('\n')}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset tracking (after compaction).
|
||||
* Keeps file access counts but clears session-specific state.
|
||||
*/
|
||||
resetSession() {
|
||||
modifiedFiles.clear();
|
||||
errors.length = 0;
|
||||
discoveries.length = 0;
|
||||
},
|
||||
|
||||
/** Get current working directory */
|
||||
getCwd() { return cwd; },
|
||||
/** Set current working directory */
|
||||
setCwd(newCwd) { cwd = newCwd; },
|
||||
};
|
||||
}
|
||||
307
src/services/costTracker.js
Normal file
307
src/services/costTracker.js
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Cost Tracker Service
|
||||
*
|
||||
* Per-model token cost tracking, USD calculation, session persistence.
|
||||
* Tracks: input/output tokens, cache hits, API duration, lines changed.
|
||||
*
|
||||
* Pricing: Anthropic + OpenAI + Groq tiers.
|
||||
* Pattern inspired by Claude Code's cost-tracker.ts & modelCost.ts.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// ── Model Pricing (USD per 1M tokens) ──────────────────────────────────
|
||||
const PRICING = {
|
||||
// Anthropic
|
||||
'claude-sonnet-4-20250514': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
||||
'claude-3-5-sonnet-20241022': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
||||
'claude-3-7-sonnet-latest': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
||||
'claude-opus-4-20250514': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
||||
'claude-3-5-haiku-20241022': { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 },
|
||||
// OpenAI
|
||||
'gpt-4o': { input: 2.5, output: 10, cacheWrite: 0, cacheRead: 1.25 },
|
||||
'gpt-4o-mini': { input: 0.15, output: 0.6, cacheWrite: 0, cacheRead: 0.075 },
|
||||
'gpt-4-turbo': { input: 10, output: 30, cacheWrite: 0, cacheRead: 5 },
|
||||
'o1': { input: 15, output: 60, cacheWrite: 0, cacheRead: 7.5 },
|
||||
'o1-mini': { input: 1.1, output: 4.4, cacheWrite: 0, cacheRead: 0.55 },
|
||||
// Groq
|
||||
'llama-3.3-70b-versatile': { input: 0.59, output: 0.79, cacheWrite: 0, cacheRead: 0 },
|
||||
'llama-3.1-8b-instant': { input: 0.05, output: 0.08, cacheWrite: 0, cacheRead: 0 },
|
||||
'mixtral-8x7b-32768': { input: 0.24, output: 0.24, cacheWrite: 0, cacheRead: 0 },
|
||||
};
|
||||
|
||||
// Fallback for unknown models — uses Sonnet-class pricing
|
||||
const DEFAULT_PRICING = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
||||
|
||||
const COST_DATA_DIR = join(process.env.HOME || '/tmp', 'alfred-agent', 'data', 'costs');
|
||||
|
||||
/**
|
||||
* Create a cost tracker instance for a session.
|
||||
*/
|
||||
export function createCostTracker(sessionId) {
|
||||
const state = {
|
||||
sessionId,
|
||||
totalCostUSD: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
totalAPIDuration: 0,
|
||||
totalToolDuration: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
queryCount: 0,
|
||||
modelUsage: {}, // { [model]: { inputTokens, outputTokens, cacheRead, cacheWrite, costUSD, queries } }
|
||||
history: [], // Last N cost events for sparkline/timeline
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Try to restore from disk
|
||||
const restored = restoreState(sessionId);
|
||||
if (restored) {
|
||||
Object.assign(state, restored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage from one API call.
|
||||
* @param {string} model - Model name (e.g. 'claude-sonnet-4-20250514')
|
||||
* @param {Object} usage - Token usage from API response
|
||||
* @param {number} usage.input_tokens
|
||||
* @param {number} usage.output_tokens
|
||||
* @param {number} [usage.cache_read_input_tokens]
|
||||
* @param {number} [usage.cache_creation_input_tokens]
|
||||
* @param {number} [apiDuration] - Time in ms for the API call
|
||||
*/
|
||||
function recordUsage(model, usage, apiDuration = 0) {
|
||||
if (!usage) return;
|
||||
|
||||
const pricing = getModelPricing(model);
|
||||
const inputTokens = usage.input_tokens || 0;
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
const cacheRead = usage.cache_read_input_tokens || 0;
|
||||
const cacheWrite = usage.cache_creation_input_tokens || 0;
|
||||
|
||||
// Calculate cost
|
||||
const cost = (
|
||||
(inputTokens / 1_000_000) * pricing.input +
|
||||
(outputTokens / 1_000_000) * pricing.output +
|
||||
(cacheRead / 1_000_000) * pricing.cacheRead +
|
||||
(cacheWrite / 1_000_000) * pricing.cacheWrite
|
||||
);
|
||||
|
||||
// Update totals
|
||||
state.totalCostUSD += cost;
|
||||
state.totalInputTokens += inputTokens;
|
||||
state.totalOutputTokens += outputTokens;
|
||||
state.totalCacheReadTokens += cacheRead;
|
||||
state.totalCacheWriteTokens += cacheWrite;
|
||||
state.totalAPIDuration += apiDuration;
|
||||
state.queryCount++;
|
||||
|
||||
// Update per-model breakdown
|
||||
if (!state.modelUsage[model]) {
|
||||
state.modelUsage[model] = {
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
cacheRead: 0, cacheWrite: 0,
|
||||
costUSD: 0, queries: 0,
|
||||
};
|
||||
}
|
||||
const mu = state.modelUsage[model];
|
||||
mu.inputTokens += inputTokens;
|
||||
mu.outputTokens += outputTokens;
|
||||
mu.cacheRead += cacheRead;
|
||||
mu.cacheWrite += cacheWrite;
|
||||
mu.costUSD += cost;
|
||||
mu.queries++;
|
||||
|
||||
// History (keep last 100 events)
|
||||
state.history.push({
|
||||
ts: Date.now(),
|
||||
model,
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
cost,
|
||||
duration: apiDuration,
|
||||
});
|
||||
if (state.history.length > 100) {
|
||||
state.history = state.history.slice(-100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record lines changed (for diff tracking).
|
||||
*/
|
||||
function recordLinesChanged(added, removed) {
|
||||
state.totalLinesAdded += added || 0;
|
||||
state.totalLinesRemoved += removed || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record tool execution time.
|
||||
*/
|
||||
function recordToolDuration(durationMs) {
|
||||
state.totalToolDuration += durationMs || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted summary for display.
|
||||
*/
|
||||
function getSummary() {
|
||||
const elapsed = (Date.now() - state.startedAt) / 1000;
|
||||
return {
|
||||
sessionId: state.sessionId,
|
||||
totalCost: formatCost(state.totalCostUSD),
|
||||
totalCostRaw: state.totalCostUSD,
|
||||
totalTokens: state.totalInputTokens + state.totalOutputTokens,
|
||||
inputTokens: state.totalInputTokens,
|
||||
outputTokens: state.totalOutputTokens,
|
||||
cacheReadTokens: state.totalCacheReadTokens,
|
||||
cacheWriteTokens: state.totalCacheWriteTokens,
|
||||
cacheHitRate: state.totalInputTokens > 0
|
||||
? ((state.totalCacheReadTokens / (state.totalInputTokens + state.totalCacheReadTokens)) * 100).toFixed(1) + '%'
|
||||
: '0%',
|
||||
queries: state.queryCount,
|
||||
avgCostPerQuery: state.queryCount > 0 ? formatCost(state.totalCostUSD / state.queryCount) : '$0.00',
|
||||
totalAPIDuration: formatDuration(state.totalAPIDuration),
|
||||
totalToolDuration: formatDuration(state.totalToolDuration),
|
||||
linesAdded: state.totalLinesAdded,
|
||||
linesRemoved: state.totalLinesRemoved,
|
||||
elapsedTime: formatDuration(elapsed * 1000),
|
||||
modelBreakdown: Object.entries(state.modelUsage).map(([model, usage]) => ({
|
||||
model: getShortName(model),
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cost: formatCost(usage.costUSD),
|
||||
queries: usage.queries,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full state for persistence.
|
||||
*/
|
||||
function getState() {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to disk.
|
||||
*/
|
||||
function save() {
|
||||
try {
|
||||
mkdirSync(COST_DATA_DIR, { recursive: true });
|
||||
const filePath = join(COST_DATA_DIR, `${state.sessionId}.json`);
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Cost tracker save failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recent cost history for charting.
|
||||
*/
|
||||
function getHistory() {
|
||||
return state.history;
|
||||
}
|
||||
|
||||
return {
|
||||
recordUsage,
|
||||
recordLinesChanged,
|
||||
recordToolDuration,
|
||||
getSummary,
|
||||
getState,
|
||||
getHistory,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function getModelPricing(model) {
|
||||
// Direct match
|
||||
if (PRICING[model]) return PRICING[model];
|
||||
// Partial match (e.g. 'claude-sonnet-4' matches 'claude-sonnet-4-20250514')
|
||||
const key = Object.keys(PRICING).find(k => model.includes(k) || k.includes(model));
|
||||
if (key) return PRICING[key];
|
||||
return DEFAULT_PRICING;
|
||||
}
|
||||
|
||||
function getShortName(model) {
|
||||
const map = {
|
||||
'claude-sonnet-4-20250514': 'sonnet-4',
|
||||
'claude-3-5-sonnet-20241022': 'sonnet-3.5',
|
||||
'claude-3-7-sonnet-latest': 'sonnet-3.7',
|
||||
'claude-opus-4-20250514': 'opus-4',
|
||||
'claude-3-5-haiku-20241022': 'haiku-3.5',
|
||||
'gpt-4o': 'gpt-4o',
|
||||
'gpt-4o-mini': 'gpt-4o-mini',
|
||||
'gpt-4-turbo': 'gpt-4-turbo',
|
||||
'o1': 'o1',
|
||||
'o1-mini': 'o1-mini',
|
||||
'llama-3.3-70b-versatile': 'llama-70b',
|
||||
'llama-3.1-8b-instant': 'llama-8b',
|
||||
'mixtral-8x7b-32768': 'mixtral',
|
||||
};
|
||||
return map[model] || model.replace(/[-_]\d{8}$/, '');
|
||||
}
|
||||
|
||||
function formatCost(cost) {
|
||||
if (cost === 0) return '$0.00';
|
||||
if (cost > 0.5) return '$' + cost.toFixed(2);
|
||||
if (cost > 0.001) return '$' + cost.toFixed(4);
|
||||
return '$' + cost.toFixed(6);
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return Math.round(ms) + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||
const m = Math.floor(ms / 60000);
|
||||
const s = Math.round((ms % 60000) / 1000);
|
||||
return m + 'm ' + s + 's';
|
||||
}
|
||||
|
||||
function restoreState(sessionId) {
|
||||
try {
|
||||
const filePath = join(COST_DATA_DIR, `${sessionId}.json`);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregate cost across all sessions.
|
||||
*/
|
||||
export function getAggregateCosts() {
|
||||
try {
|
||||
if (!existsSync(COST_DATA_DIR)) return { totalCost: '$0.00', sessions: 0 };
|
||||
const files = require('fs').readdirSync(COST_DATA_DIR).filter(f => f.endsWith('.json'));
|
||||
let totalUSD = 0;
|
||||
let totalTokens = 0;
|
||||
let totalQueries = 0;
|
||||
for (const f of files) {
|
||||
try {
|
||||
const d = JSON.parse(readFileSync(join(COST_DATA_DIR, f), 'utf8'));
|
||||
totalUSD += d.totalCostUSD || 0;
|
||||
totalTokens += (d.totalInputTokens || 0) + (d.totalOutputTokens || 0);
|
||||
totalQueries += d.queryCount || 0;
|
||||
} catch { /* skip corrupt files */ }
|
||||
}
|
||||
return {
|
||||
totalCost: formatCost(totalUSD),
|
||||
totalCostRaw: totalUSD,
|
||||
totalTokens,
|
||||
totalQueries,
|
||||
sessions: files.length,
|
||||
};
|
||||
} catch {
|
||||
return { totalCost: '$0.00', sessions: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export { PRICING, getModelPricing, formatCost, formatDuration };
|
||||
363
src/services/decayMemory.js
Normal file
363
src/services/decayMemory.js
Normal file
@ -0,0 +1,363 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED BRAIN — Memory Decay Engine (Node.js)
|
||||
*
|
||||
* Facts automatically lose confidence over time. Reinforced facts last longer.
|
||||
* Three decay profiles: standard (project), global (cross-project), recent_work (session).
|
||||
*
|
||||
* Direct port of Omahon decay.rs — produces identical results.
|
||||
* Uses the same SQLite DB as the PHP factstore for shared state.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
|
||||
const DEFAULT_DB_PATH = join(homedir(), 'alfred-services', 'memory', 'alfred-memory.db');
|
||||
const MAX_HALF_LIFE_DAYS = 90.0;
|
||||
const LN2 = Math.log(2);
|
||||
|
||||
/** Decay profiles: { half_life, reinforcement_factor } */
|
||||
const PROFILES = {
|
||||
standard: { half_life: 14.0, reinforcement_factor: 1.8 },
|
||||
global: { half_life: 30.0, reinforcement_factor: 2.5 },
|
||||
recent_work: { half_life: 2.0, reinforcement_factor: 1.0 },
|
||||
};
|
||||
|
||||
// ── Pure Decay Math ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute confidence for a fact.
|
||||
* @param {number} daysSince - Days since last reinforcement
|
||||
* @param {number} reinforcements - Number of reinforcements (≥1)
|
||||
* @param {string} profile - Decay profile name
|
||||
* @returns {number} Confidence in [0, 1]
|
||||
*/
|
||||
export function confidence(daysSince, reinforcements = 1, profile = 'standard') {
|
||||
const p = PROFILES[profile] || PROFILES.standard;
|
||||
const rawHalfLife = p.half_life * Math.pow(p.reinforcement_factor, reinforcements - 1);
|
||||
const halfLife = Math.min(rawHalfLife, MAX_HALF_LIFE_DAYS);
|
||||
return Math.max(0, Math.exp(-LN2 * daysSince / halfLife));
|
||||
}
|
||||
|
||||
/**
|
||||
* Days until confidence drops below threshold.
|
||||
*/
|
||||
export function daysUntil(threshold = 0.1, reinforcements = 1, profile = 'standard') {
|
||||
const p = PROFILES[profile] || PROFILES.standard;
|
||||
const rawHalfLife = p.half_life * Math.pow(p.reinforcement_factor, reinforcements - 1);
|
||||
const halfLife = Math.min(rawHalfLife, MAX_HALF_LIFE_DAYS);
|
||||
return -halfLife * Math.log(threshold) / LN2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content hash for deduplication.
|
||||
*/
|
||||
function contentHash(content) {
|
||||
return createHash('sha256').update(content.trim().toLowerCase()).digest('hex');
|
||||
}
|
||||
|
||||
// ── Fact Store ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a memory store backed by SQLite.
|
||||
* Shares the DB with the PHP factstore for interop.
|
||||
*/
|
||||
export function createMemoryStore(dbPath = DEFAULT_DB_PATH) {
|
||||
const dir = dirname(dbPath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
// Schema is already created by PHP factstore — just ensure base tables exist
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS minds (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
origin_type TEXT, origin_path TEXT,
|
||||
readonly INTEGER NOT NULL DEFAULT 0,
|
||||
parent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO minds (name) VALUES ('default');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
mind TEXT NOT NULL DEFAULT 'default',
|
||||
section TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_session TEXT,
|
||||
supersedes TEXT, superseded_at TEXT, archived_at TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
content_hash TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
last_reinforced TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reinforcement_count INTEGER NOT NULL DEFAULT 1,
|
||||
decay_profile TEXT NOT NULL DEFAULT 'standard',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
last_accessed TEXT,
|
||||
persona_id TEXT,
|
||||
layer TEXT NOT NULL DEFAULT 'project',
|
||||
tags TEXT,
|
||||
FOREIGN KEY (mind) REFERENCES minds(name) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_active ON facts(mind, status) WHERE status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_hash ON facts(mind, content_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_fact_id TEXT NOT NULL,
|
||||
target_fact_id TEXT NOT NULL,
|
||||
relation TEXT NOT NULL,
|
||||
description TEXT,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
last_reinforced TEXT DEFAULT (datetime('now')),
|
||||
reinforcement_count INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_session TEXT,
|
||||
FOREIGN KEY (source_fact_id) REFERENCES facts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_fact_id) REFERENCES facts(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
mind TEXT NOT NULL DEFAULT 'default',
|
||||
title TEXT NOT NULL,
|
||||
narrative TEXT NOT NULL,
|
||||
date TEXT NOT NULL DEFAULT (date('now')),
|
||||
session_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (mind) REFERENCES minds(name) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Prepared statements
|
||||
const stmts = {
|
||||
insertFact: db.prepare(`
|
||||
INSERT INTO facts (id, mind, section, content, content_hash, source, created_session, decay_profile, layer, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
findByHash: db.prepare(`
|
||||
SELECT id, reinforcement_count FROM facts WHERE mind = ? AND content_hash = ? AND status = 'active'
|
||||
`),
|
||||
reinforce: db.prepare(`
|
||||
UPDATE facts SET reinforcement_count = reinforcement_count + 1,
|
||||
last_reinforced = datetime('now'), confidence = 1.0, version = version + 1
|
||||
WHERE id = ?
|
||||
`),
|
||||
recallActive: db.prepare(`
|
||||
SELECT id, mind, section, content, confidence, last_reinforced, reinforcement_count,
|
||||
decay_profile, created_at, tags, layer
|
||||
FROM facts WHERE mind = ? AND status = 'active'
|
||||
ORDER BY confidence DESC, last_reinforced DESC
|
||||
`),
|
||||
recallBySection: db.prepare(`
|
||||
SELECT id, mind, section, content, confidence, last_reinforced, reinforcement_count,
|
||||
decay_profile, created_at, tags
|
||||
FROM facts WHERE mind = ? AND section = ? AND status = 'active'
|
||||
ORDER BY confidence DESC
|
||||
`),
|
||||
archive: db.prepare(`
|
||||
UPDATE facts SET status = 'archived', archived_at = datetime('now') WHERE id = ?
|
||||
`),
|
||||
supersede: db.prepare(`
|
||||
UPDATE facts SET status = 'superseded', superseded_at = datetime('now'), supersedes = ? WHERE id = ?
|
||||
`),
|
||||
touchAccess: db.prepare(`UPDATE facts SET last_accessed = datetime('now') WHERE id = ?`),
|
||||
insertEdge: db.prepare(`
|
||||
INSERT INTO facts (id, mind, section, content, content_hash, source, created_session, decay_profile, layer, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
insertEpisode: db.prepare(`
|
||||
INSERT INTO episodes (id, mind, title, narrative, session_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`),
|
||||
listMinds: db.prepare(`SELECT name, description, status FROM minds`),
|
||||
createMind: db.prepare(`INSERT OR IGNORE INTO minds (name, description) VALUES (?, ?)`),
|
||||
countActive: db.prepare(`SELECT COUNT(*) as cnt FROM facts WHERE mind = ? AND status = 'active'`),
|
||||
};
|
||||
|
||||
function newId() { return randomBytes(16).toString('hex'); }
|
||||
|
||||
/**
|
||||
* Store a fact. If the same content (by hash) exists in this mind, reinforce instead.
|
||||
*/
|
||||
function store(mind, section, content, opts = {}) {
|
||||
const hash = contentHash(content);
|
||||
const existing = stmts.findByHash.get(mind, hash);
|
||||
|
||||
if (existing) {
|
||||
stmts.reinforce.run(existing.id);
|
||||
return { id: existing.id, action: 'reinforced', count: existing.reinforcement_count + 1 };
|
||||
}
|
||||
|
||||
const id = newId();
|
||||
stmts.insertFact.run(
|
||||
id, mind, section, content, hash,
|
||||
opts.source || 'agent',
|
||||
opts.sessionId || null,
|
||||
opts.decayProfile || 'standard',
|
||||
opts.layer || 'project',
|
||||
opts.tags ? JSON.stringify(opts.tags) : null,
|
||||
);
|
||||
return { id, action: 'stored' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recall active facts from a mind with live-computed decay confidence.
|
||||
*/
|
||||
function recall(mind, opts = {}) {
|
||||
const rows = opts.section
|
||||
? stmts.recallBySection.all(mind, opts.section)
|
||||
: stmts.recallActive.all(mind);
|
||||
|
||||
const now = Date.now();
|
||||
const minConfidence = opts.minConfidence ?? 0.1;
|
||||
|
||||
return rows
|
||||
.map(row => {
|
||||
const lastReinforced = new Date(row.last_reinforced + 'Z').getTime();
|
||||
const daysSince = (now - lastReinforced) / (1000 * 60 * 60 * 24);
|
||||
const liveConfidence = confidence(daysSince, row.reinforcement_count, row.decay_profile);
|
||||
|
||||
// Touch access time
|
||||
stmts.touchAccess.run(row.id);
|
||||
|
||||
return {
|
||||
...row,
|
||||
confidence: liveConfidence,
|
||||
daysSince: Math.round(daysSince * 10) / 10,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
};
|
||||
})
|
||||
.filter(f => f.confidence >= minConfidence)
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, opts.limit || 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search facts by keyword using LIKE (FTS5 may not be available).
|
||||
*/
|
||||
function search(query, opts = {}) {
|
||||
const mind = opts.mind || 'default';
|
||||
const stmt = db.prepare(
|
||||
`SELECT id, mind, section, content, confidence, last_reinforced, reinforcement_count, decay_profile
|
||||
FROM facts WHERE mind = ? AND status = 'active'
|
||||
AND content LIKE ? ORDER BY confidence DESC LIMIT ?`
|
||||
);
|
||||
return stmt.all(mind, `%${query}%`, opts.limit || 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a fact (soft delete).
|
||||
*/
|
||||
function archive(factId) {
|
||||
stmts.archive.run(factId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supersede a fact with a new one.
|
||||
*/
|
||||
function supersede(oldFactId, mind, section, newContent, opts = {}) {
|
||||
const result = store(mind, section, newContent, opts);
|
||||
if (result.action === 'stored') {
|
||||
stmts.supersede.run(result.id, oldFactId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect two facts with a relation.
|
||||
*/
|
||||
function connect(sourceId, targetId, relation, description = null) {
|
||||
const id = newId();
|
||||
db.prepare(
|
||||
`INSERT INTO edges (id, source_fact_id, target_fact_id, relation, description)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, sourceId, targetId, relation, description);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an episode (narrative summary of a work session).
|
||||
*/
|
||||
function recordEpisode(mind, title, narrative, sessionId = null) {
|
||||
const id = newId();
|
||||
stmts.insertEpisode.run(id, mind, title, narrative, sessionId);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get a mind namespace.
|
||||
*/
|
||||
function ensureMind(name, description = '') {
|
||||
stmts.createMind.run(name, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render memory context for system prompt injection.
|
||||
* Returns structured block with highest-confidence facts.
|
||||
*/
|
||||
function renderContext(mind, maxFacts = 20) {
|
||||
const facts = recall(mind, { limit: maxFacts, minConfidence: 0.2 });
|
||||
if (facts.length === 0) return '';
|
||||
|
||||
const lines = [`<memory mind="${mind}" facts="${facts.length}">`];
|
||||
const bySections = {};
|
||||
for (const f of facts) {
|
||||
(bySections[f.section] = bySections[f.section] || []).push(f);
|
||||
}
|
||||
for (const [section, sectionFacts] of Object.entries(bySections)) {
|
||||
lines.push(` <section name="${section}">`);
|
||||
for (const f of sectionFacts) {
|
||||
const conf = Math.round(f.confidence * 100);
|
||||
lines.push(` <fact confidence="${conf}%">${f.content}</fact>`);
|
||||
}
|
||||
lines.push(` </section>`);
|
||||
}
|
||||
lines.push('</memory>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run decay sweep — archive facts below threshold.
|
||||
*/
|
||||
function decaySweep(mind, threshold = 0.05) {
|
||||
const facts = recall(mind, { minConfidence: 0, limit: 10000 });
|
||||
let archived = 0;
|
||||
for (const f of facts) {
|
||||
if (f.confidence < threshold) {
|
||||
archive(f.id);
|
||||
archived++;
|
||||
}
|
||||
}
|
||||
return archived;
|
||||
}
|
||||
|
||||
function stats(mind) {
|
||||
return stmts.countActive.get(mind);
|
||||
}
|
||||
|
||||
function close() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return {
|
||||
store, recall, search, archive, supersede, connect,
|
||||
recordEpisode, ensureMind, renderContext, decaySweep,
|
||||
stats, close,
|
||||
// Expose pure math for tests
|
||||
confidence, daysUntil,
|
||||
};
|
||||
}
|
||||
418
src/services/doctor.js
Normal file
418
src/services/doctor.js
Normal file
@ -0,0 +1,418 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Doctor / Diagnostics Service
|
||||
*
|
||||
* Self-check of the entire Alfred ecosystem:
|
||||
* - API key verification (Anthropic, OpenAI, Groq)
|
||||
* - PM2 service health (47 services)
|
||||
* - MCP connectivity (875 tools)
|
||||
* - GoForge status
|
||||
* - Workspace integrity
|
||||
* - Disk/memory health
|
||||
* - Session data health
|
||||
* - Extension / code-server status
|
||||
* - Unified workspace validation
|
||||
*
|
||||
* Pattern inspired by Claude Code's doctor/diagnostics.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const HOME = process.env.HOME || '/home/gositeme';
|
||||
const AGENT_DIR = join(HOME, 'alfred-agent');
|
||||
const DATA_DIR = join(AGENT_DIR, 'data');
|
||||
|
||||
/**
|
||||
* Run the full diagnostic suite.
|
||||
* Returns an array of check results.
|
||||
*/
|
||||
export async function runDiagnostics() {
|
||||
const checks = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
// ── 1. Agent Runtime ─────────────────────────────────────────────
|
||||
checks.push(checkAgentRuntime());
|
||||
|
||||
// ── 2. API Keys ──────────────────────────────────────────────────
|
||||
checks.push(...checkAPIKeys());
|
||||
|
||||
// ── 3. PM2 Services ──────────────────────────────────────────────
|
||||
checks.push(await checkPM2());
|
||||
|
||||
// ── 4. MCP Server ────────────────────────────────────────────────
|
||||
checks.push(await checkMCP());
|
||||
|
||||
// ── 5. GoForge ───────────────────────────────────────────────────
|
||||
checks.push(await checkGoForge());
|
||||
|
||||
// ── 6. Code-Server / IDE ─────────────────────────────────────────
|
||||
checks.push(checkCodeServer());
|
||||
|
||||
// ── 7. Disk & Memory ─────────────────────────────────────────────
|
||||
checks.push(checkDiskSpace());
|
||||
checks.push(checkMemory());
|
||||
|
||||
// ── 8. Session & Data Integrity ──────────────────────────────────
|
||||
checks.push(checkSessionData());
|
||||
|
||||
// ── 9. Unified Workspace ─────────────────────────────────────────
|
||||
checks.push(checkUnifiedWorkspace());
|
||||
|
||||
// ── 10. Source Files ─────────────────────────────────────────────
|
||||
checks.push(checkSourceFiles());
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────
|
||||
const passed = checks.filter(c => c.status === 'ok').length;
|
||||
const warnings = checks.filter(c => c.status === 'warning').length;
|
||||
const failed = checks.filter(c => c.status === 'error').length;
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: checks.length,
|
||||
passed,
|
||||
warnings,
|
||||
failed,
|
||||
health: failed === 0 ? (warnings === 0 ? 'healthy' : 'degraded') : 'unhealthy',
|
||||
elapsed: `${elapsed}ms`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Individual Check Functions ─────────────────────────────────────────
|
||||
|
||||
function checkAgentRuntime() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(AGENT_DIR, 'package.json'), 'utf8'));
|
||||
const srcFiles = readdirSync(join(AGENT_DIR, 'src')).filter(f => f.endsWith('.js'));
|
||||
const svcFiles = readdirSync(join(AGENT_DIR, 'src', 'services')).filter(f => f.endsWith('.js'));
|
||||
|
||||
return {
|
||||
name: 'Agent Runtime',
|
||||
status: 'ok',
|
||||
details: {
|
||||
version: pkg.version || '2.0.0',
|
||||
sourceFiles: srcFiles.length,
|
||||
serviceModules: svcFiles.length,
|
||||
services: svcFiles.map(f => f.replace('.js', '')),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'Agent Runtime', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function checkAPIKeys() {
|
||||
const checks = [];
|
||||
const keyLocations = {
|
||||
anthropic: [
|
||||
`${HOME}/.vault/keys/anthropic.key`,
|
||||
'/run/user/1004/keys/anthropic.key',
|
||||
],
|
||||
openai: [
|
||||
`${HOME}/.vault/keys/openai.key`,
|
||||
'/run/user/1004/keys/openai.key',
|
||||
],
|
||||
groq: [
|
||||
`${HOME}/.vault/keys/groq.key`,
|
||||
'/run/user/1004/keys/groq.key',
|
||||
],
|
||||
};
|
||||
|
||||
for (const [provider, paths] of Object.entries(keyLocations)) {
|
||||
let found = false;
|
||||
let source = null;
|
||||
let prefix = null;
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const key = readFileSync(p, 'utf8').trim();
|
||||
if (key.length >= 10) {
|
||||
found = true;
|
||||
source = p.includes('.vault') ? 'vault' : 'tmpfs';
|
||||
prefix = key.slice(0, 12) + '...';
|
||||
break;
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
// Also check environment
|
||||
if (!found) {
|
||||
const envKey = `${provider.toUpperCase()}_API_KEY`;
|
||||
if (process.env[envKey] && process.env[envKey].length >= 10) {
|
||||
found = true;
|
||||
source = 'env';
|
||||
prefix = process.env[envKey].slice(0, 12) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
checks.push({
|
||||
name: `API Key: ${provider}`,
|
||||
status: found ? 'ok' : (provider === 'anthropic' ? 'error' : 'warning'),
|
||||
details: found
|
||||
? { source, prefix }
|
||||
: { message: `No ${provider} API key found` },
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
async function checkPM2() {
|
||||
try {
|
||||
const pm2Bin = join(HOME, '.local/node_modules/.bin/pm2');
|
||||
const output = execSync(`HOME=${HOME} PM2_HOME=${HOME}/.pm2 ${pm2Bin} jlist 2>/dev/null`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const services = JSON.parse(output || '[]');
|
||||
const online = services.filter(s => s.pm2_env?.status === 'online').length;
|
||||
const stopped = services.filter(s => s.pm2_env?.status === 'stopped').length;
|
||||
const errored = services.filter(s => s.pm2_env?.status === 'errored').length;
|
||||
|
||||
return {
|
||||
name: 'PM2 Services',
|
||||
status: errored > 0 ? 'warning' : 'ok',
|
||||
details: {
|
||||
total: services.length,
|
||||
online,
|
||||
stopped,
|
||||
errored,
|
||||
services: services.map(s => ({
|
||||
name: s.name,
|
||||
id: s.pm_id,
|
||||
status: s.pm2_env?.status,
|
||||
memory: s.monit?.memory ? `${(s.monit.memory / 1e6).toFixed(0)}MB` : '?',
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'PM2 Services', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkMCP() {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:3006/mcp/docs/summary', { signal: AbortSignal.timeout(5000) });
|
||||
const data = await response.json();
|
||||
return {
|
||||
name: 'MCP Server',
|
||||
status: 'ok',
|
||||
details: {
|
||||
totalTools: data.totalTools || 0,
|
||||
categories: data.categories?.length || 0,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { name: 'MCP Server', status: 'error', message: 'MCP unreachable at port 3006' };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGoForge() {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:3300/api/v1/repos/search?limit=1', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.ok) {
|
||||
return { name: 'GoForge', status: 'ok', details: { port: 3300 } };
|
||||
}
|
||||
return { name: 'GoForge', status: 'warning', message: `HTTP ${response.status}` };
|
||||
} catch {
|
||||
return { name: 'GoForge', status: 'error', message: 'GoForge unreachable at port 3300' };
|
||||
}
|
||||
}
|
||||
|
||||
function checkCodeServer() {
|
||||
try {
|
||||
const configPath = join(HOME, '.config/code-server/config.yaml');
|
||||
const configExists = existsSync(configPath);
|
||||
|
||||
const extensionDir = join(HOME, '.local/share/code-server/extensions');
|
||||
const extensions = existsSync(extensionDir)
|
||||
? readdirSync(extensionDir).filter(d => !d.startsWith('.'))
|
||||
: [];
|
||||
|
||||
const commanderExt = extensions.find(e => e.includes('alfred-commander'));
|
||||
|
||||
return {
|
||||
name: 'Code-Server / IDE',
|
||||
status: configExists ? 'ok' : 'warning',
|
||||
details: {
|
||||
configExists,
|
||||
extensions: extensions.length,
|
||||
commanderExtension: commanderExt || 'not found',
|
||||
extensionList: extensions,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'Code-Server / IDE', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function checkDiskSpace() {
|
||||
try {
|
||||
const output = execSync("df -h /home/gositeme | tail -1", { encoding: 'utf8', timeout: 5000 });
|
||||
const parts = output.trim().split(/\s+/);
|
||||
const usedPct = parseInt(parts[4]) || 0;
|
||||
return {
|
||||
name: 'Disk Space',
|
||||
status: usedPct > 90 ? 'error' : usedPct > 75 ? 'warning' : 'ok',
|
||||
details: {
|
||||
total: parts[1],
|
||||
used: parts[2],
|
||||
available: parts[3],
|
||||
usedPercent: usedPct,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { name: 'Disk Space', status: 'warning', message: 'Could not check disk' };
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemory() {
|
||||
try {
|
||||
const output = execSync("free -m | grep Mem", { encoding: 'utf8', timeout: 5000 });
|
||||
const parts = output.trim().split(/\s+/);
|
||||
const totalMB = parseInt(parts[1]) || 1;
|
||||
const usedMB = parseInt(parts[2]) || 0;
|
||||
const pct = Math.round((usedMB / totalMB) * 100);
|
||||
return {
|
||||
name: 'Memory',
|
||||
status: pct > 90 ? 'error' : pct > 75 ? 'warning' : 'ok',
|
||||
details: {
|
||||
totalMB,
|
||||
usedMB,
|
||||
availableMB: parseInt(parts[6]) || 0,
|
||||
usedPercent: pct,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { name: 'Memory', status: 'warning', message: 'Could not check memory' };
|
||||
}
|
||||
}
|
||||
|
||||
function checkSessionData() {
|
||||
try {
|
||||
const sessDir = join(DATA_DIR, 'sessions');
|
||||
const sessions = existsSync(sessDir)
|
||||
? readdirSync(sessDir).filter(f => f.endsWith('.json'))
|
||||
: [];
|
||||
|
||||
let corruptCount = 0;
|
||||
for (const s of sessions) {
|
||||
try {
|
||||
JSON.parse(readFileSync(join(sessDir, s), 'utf8'));
|
||||
} catch {
|
||||
corruptCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Session Data',
|
||||
status: corruptCount > 0 ? 'warning' : 'ok',
|
||||
details: {
|
||||
totalSessions: sessions.length,
|
||||
corruptSessions: corruptCount,
|
||||
dataDir: DATA_DIR,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'Session Data', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function checkUnifiedWorkspace() {
|
||||
const unified = join(HOME, 'alfred-workspace-unified');
|
||||
try {
|
||||
if (!existsSync(unified)) {
|
||||
return { name: 'Unified Workspace', status: 'warning', message: 'Not created yet. Run consolidate-workspace.sh' };
|
||||
}
|
||||
|
||||
const dirs = readdirSync(unified).filter(d => {
|
||||
try { return statSync(join(unified, d)).isDirectory(); } catch { return false; }
|
||||
});
|
||||
|
||||
const fileCounts = {};
|
||||
for (const d of dirs) {
|
||||
try {
|
||||
fileCounts[d] = readdirSync(join(unified, d)).length;
|
||||
} catch {
|
||||
fileCounts[d] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const totalFiles = Object.values(fileCounts).reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
name: 'Unified Workspace',
|
||||
status: totalFiles > 0 ? 'ok' : 'warning',
|
||||
details: {
|
||||
path: unified,
|
||||
directories: dirs.length,
|
||||
totalFiles,
|
||||
breakdown: fileCounts,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'Unified Workspace', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function checkSourceFiles() {
|
||||
try {
|
||||
const srcDir = join(AGENT_DIR, 'src');
|
||||
const svcDir = join(srcDir, 'services');
|
||||
let totalLines = 0;
|
||||
|
||||
const countLines = (dir) => {
|
||||
const files = readdirSync(dir).filter(f => f.endsWith('.js'));
|
||||
for (const f of files) {
|
||||
const content = readFileSync(join(dir, f), 'utf8');
|
||||
totalLines += content.split('\n').length;
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const srcFiles = countLines(srcDir);
|
||||
const svcFiles = countLines(svcDir);
|
||||
|
||||
return {
|
||||
name: 'Source Files',
|
||||
status: 'ok',
|
||||
details: {
|
||||
coreFiles: srcFiles.length,
|
||||
serviceModules: svcFiles.length,
|
||||
totalFiles: srcFiles.length + svcFiles.length,
|
||||
totalLines,
|
||||
fileList: [...srcFiles.map(f => `src/${f}`), ...svcFiles.map(f => `src/services/${f}`)],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { name: 'Source Files', status: 'error', message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a quick health check (subset of full diagnostics).
|
||||
* Faster — suitable for status bar display.
|
||||
*/
|
||||
export async function quickHealth() {
|
||||
const agentCheck = checkAgentRuntime();
|
||||
const diskCheck = checkDiskSpace();
|
||||
const memCheck = checkMemory();
|
||||
|
||||
return {
|
||||
agent: agentCheck.status,
|
||||
disk: diskCheck.details?.usedPercent,
|
||||
memory: memCheck.details?.usedPercent,
|
||||
healthy: agentCheck.status === 'ok' && diskCheck.status !== 'error' && memCheck.status !== 'error',
|
||||
};
|
||||
}
|
||||
|
||||
export { checkAPIKeys, checkPM2, checkGoForge, checkMCP };
|
||||
270
src/services/intent.js
Normal file
270
src/services/intent.js
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED BRAIN — Intent Document (Structured Session State)
|
||||
*
|
||||
* Tracks the current task, approach, lifecycle phase, files touched,
|
||||
* failed approaches, constraints, and open questions.
|
||||
*
|
||||
* Key property: SURVIVES COMPACTION VERBATIM.
|
||||
* The compaction engine must preserve the intent block as-is.
|
||||
*
|
||||
* Pattern: Omahon conversation.rs IntentDocument
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const SESSIONS_DIR = join(homedir(), '.alfred', 'sessions');
|
||||
|
||||
const LIFECYCLE_PHASES = [
|
||||
'exploring', // Reading code, gathering context
|
||||
'specifying', // Defining what to change
|
||||
'decomposing', // Breaking into subtasks
|
||||
'implementing', // Writing code
|
||||
'verifying', // Testing and validation
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an IntentDocument for a session.
|
||||
*/
|
||||
export function createIntent(sessionId) {
|
||||
mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
|
||||
const state = {
|
||||
sessionId,
|
||||
currentTask: null,
|
||||
approach: null,
|
||||
lifecyclePhase: 'exploring',
|
||||
filesRead: [], // ordered set
|
||||
filesModified: [], // ordered set
|
||||
commitNudged: false,
|
||||
constraints: [], // deduplicated
|
||||
failedApproaches: [], // {approach, reason}[]
|
||||
openQuestions: [], // deduplicated
|
||||
stats: {
|
||||
turns: 0,
|
||||
toolCalls: 0,
|
||||
tokensConsumed: 0,
|
||||
compactions: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Try to load existing state
|
||||
const filePath = join(SESSIONS_DIR, `intent-${sessionId}.json`);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const saved = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
Object.assign(state, saved);
|
||||
} catch { /* start fresh */ }
|
||||
}
|
||||
|
||||
// ── Mutators ────────────────────────────────────────────────────
|
||||
|
||||
function setTask(task) {
|
||||
state.currentTask = task;
|
||||
}
|
||||
|
||||
function setApproach(approach) {
|
||||
state.approach = approach;
|
||||
}
|
||||
|
||||
function setPhase(phase) {
|
||||
if (LIFECYCLE_PHASES.includes(phase)) {
|
||||
state.lifecyclePhase = phase;
|
||||
}
|
||||
}
|
||||
|
||||
function trackRead(filePath) {
|
||||
if (!state.filesRead.includes(filePath)) {
|
||||
state.filesRead.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function trackModified(filePath) {
|
||||
if (!state.filesModified.includes(filePath)) {
|
||||
state.filesModified.push(filePath);
|
||||
}
|
||||
// Also track as read
|
||||
trackRead(filePath);
|
||||
}
|
||||
|
||||
function trackCommit() {
|
||||
state.filesModified = [];
|
||||
state.commitNudged = false;
|
||||
}
|
||||
|
||||
function addConstraint(constraint) {
|
||||
const trimmed = constraint.trim();
|
||||
if (trimmed && !state.constraints.includes(trimmed)) {
|
||||
state.constraints.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function failedApproach(approach, reason) {
|
||||
state.failedApproaches.push({ approach, reason, at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
function addQuestion(question) {
|
||||
const trimmed = question.trim();
|
||||
if (trimmed && !state.openQuestions.includes(trimmed)) {
|
||||
state.openQuestions.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveQuestion(question) {
|
||||
state.openQuestions = state.openQuestions.filter(q => q !== question.trim());
|
||||
}
|
||||
|
||||
function incrementTurn() { state.stats.turns++; }
|
||||
function incrementToolCalls(n = 1) { state.stats.toolCalls += n; }
|
||||
function addTokens(n) { state.stats.tokensConsumed += n; }
|
||||
function incrementCompactions() { state.stats.compactions++; }
|
||||
|
||||
// ── Auto-populate from tool dispatch ────────────────────────────
|
||||
|
||||
/**
|
||||
* Call this after each tool execution to auto-track context.
|
||||
*/
|
||||
function trackToolUse(toolName, input) {
|
||||
state.stats.toolCalls++;
|
||||
|
||||
switch (toolName) {
|
||||
case 'read_file':
|
||||
if (input?.path) trackRead(input.path);
|
||||
break;
|
||||
case 'write_file':
|
||||
case 'edit_file':
|
||||
if (input?.path) trackModified(input.path);
|
||||
break;
|
||||
case 'bash':
|
||||
// Check for git commit
|
||||
if (input?.command && /git\s+commit/i.test(input.command)) {
|
||||
trackCommit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Auto-detect lifecycle phase transitions
|
||||
if (!state.currentTask && state.stats.toolCalls === 1) {
|
||||
state.lifecyclePhase = 'exploring';
|
||||
}
|
||||
if (state.filesModified.length > 0 && state.lifecyclePhase === 'exploring') {
|
||||
state.lifecyclePhase = 'implementing';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ambient capture from assistant output ──────────────────────
|
||||
|
||||
/**
|
||||
* Parse `omg:` tags from assistant text for ambient metadata capture.
|
||||
* Supported: omg:phase, omg:constraint, omg:question, omg:approach,
|
||||
* omg:failed, omg:decision, omg:task
|
||||
*/
|
||||
function parseAmbient(text) {
|
||||
if (!text || typeof text !== 'string') return;
|
||||
|
||||
const tagPattern = /omg:(\w+)\s+(.+?)(?=omg:|$)/gi;
|
||||
let match;
|
||||
while ((match = tagPattern.exec(text)) !== null) {
|
||||
const [, tag, value] = match;
|
||||
const val = value.trim();
|
||||
switch (tag.toLowerCase()) {
|
||||
case 'phase': setPhase(val); break;
|
||||
case 'constraint': addConstraint(val); break;
|
||||
case 'question': addQuestion(val); break;
|
||||
case 'approach': setApproach(val); break;
|
||||
case 'task': setTask(val); break;
|
||||
case 'failed': {
|
||||
const parts = val.split('|').map(s => s.trim());
|
||||
failedApproach(parts[0] || val, parts[1] || 'Did not work');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render for LLM injection ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the intent document as a structured block for the system prompt.
|
||||
* This block SURVIVES COMPACTION — never decayed or summarized.
|
||||
*/
|
||||
function render() {
|
||||
const lines = ['<intent_document>'];
|
||||
|
||||
if (state.currentTask) {
|
||||
lines.push(`<current_task>${state.currentTask}</current_task>`);
|
||||
}
|
||||
if (state.approach) {
|
||||
lines.push(`<approach>${state.approach}</approach>`);
|
||||
}
|
||||
lines.push(`<lifecycle_phase>${state.lifecyclePhase}</lifecycle_phase>`);
|
||||
|
||||
if (state.filesRead.length > 0) {
|
||||
lines.push(`<files_read count="${state.filesRead.length}">`);
|
||||
// Show last 20 files to avoid bloat
|
||||
const recent = state.filesRead.slice(-20);
|
||||
lines.push(recent.join('\n'));
|
||||
if (state.filesRead.length > 20) {
|
||||
lines.push(`... and ${state.filesRead.length - 20} more`);
|
||||
}
|
||||
lines.push('</files_read>');
|
||||
}
|
||||
|
||||
if (state.filesModified.length > 0) {
|
||||
lines.push(`<files_modified count="${state.filesModified.length}">`);
|
||||
lines.push(state.filesModified.join('\n'));
|
||||
lines.push('</files_modified>');
|
||||
}
|
||||
|
||||
if (state.constraints.length > 0) {
|
||||
lines.push('<constraints>');
|
||||
state.constraints.forEach(c => lines.push(`- ${c}`));
|
||||
lines.push('</constraints>');
|
||||
}
|
||||
|
||||
if (state.failedApproaches.length > 0) {
|
||||
lines.push('<failed_approaches>');
|
||||
state.failedApproaches.forEach(f => {
|
||||
lines.push(`- ${f.approach}: ${f.reason}`);
|
||||
});
|
||||
lines.push('</failed_approaches>');
|
||||
}
|
||||
|
||||
if (state.openQuestions.length > 0) {
|
||||
lines.push('<open_questions>');
|
||||
state.openQuestions.forEach(q => lines.push(`- ${q}`));
|
||||
lines.push('</open_questions>');
|
||||
}
|
||||
|
||||
lines.push(`<session_stats turns="${state.stats.turns}" tools="${state.stats.toolCalls}" tokens="${state.stats.tokensConsumed}" compactions="${state.stats.compactions}" />`);
|
||||
lines.push('</intent_document>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────
|
||||
|
||||
function save() {
|
||||
const fp = join(SESSIONS_DIR, `intent-${state.sessionId}.json`);
|
||||
writeFileSync(fp, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
return {
|
||||
setTask, setApproach, setPhase,
|
||||
trackRead, trackModified, trackCommit,
|
||||
addConstraint, failedApproach, addQuestion, resolveQuestion,
|
||||
incrementTurn, incrementToolCalls, addTokens, incrementCompactions,
|
||||
trackToolUse, parseAmbient,
|
||||
render, save, getState,
|
||||
// Expose phase list for external use
|
||||
LIFECYCLE_PHASES,
|
||||
};
|
||||
}
|
||||
368
src/services/memory.js
Normal file
368
src/services/memory.js
Normal file
@ -0,0 +1,368 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Persistent Memory Service
|
||||
*
|
||||
* Cross-session memory with:
|
||||
* - Write memories from conversations (key facts, preferences, patterns)
|
||||
* - Read/recall relevant memories on new sessions
|
||||
* - Memory types: user, project, session, system
|
||||
* - Auto-extraction via background analysis
|
||||
* - Linked to unified workspace at ~/alfred-workspace-unified/memories/
|
||||
*
|
||||
* Pattern inspired by Claude Code's SessionMemory + extractMemories.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, unlinkSync, statSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
const MEMORY_BASE = join(process.env.HOME || '/tmp', 'alfred-agent', 'data', 'memories');
|
||||
const UNIFIED_MEMORIES = join(process.env.HOME || '/tmp', 'alfred-workspace-unified', 'memories');
|
||||
|
||||
// Memory types with retention policies
|
||||
const MEMORY_TYPES = {
|
||||
user: { dir: 'user', description: 'User preferences, patterns, personal context', persist: true },
|
||||
project: { dir: 'project', description: 'Project-specific facts, conventions, architecture', persist: true },
|
||||
session: { dir: 'session', description: 'Current session working state', persist: false },
|
||||
system: { dir: 'system', description: 'System facts, server config, infrastructure', persist: true },
|
||||
journal: { dir: 'journal', description: 'Session journals and build logs', persist: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a memory manager instance.
|
||||
*/
|
||||
export function createMemoryManager() {
|
||||
// Ensure all memory type directories exist
|
||||
for (const [, config] of Object.entries(MEMORY_TYPES)) {
|
||||
mkdirSync(join(MEMORY_BASE, config.dir), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a memory to disk.
|
||||
* @param {string} type - Memory type: user, project, session, system, journal
|
||||
* @param {string} slug - Short filename (without extension)
|
||||
* @param {string} content - Markdown content
|
||||
* @param {Object} metadata - Optional frontmatter fields
|
||||
*/
|
||||
function writeMemory(type, slug, content, metadata = {}) {
|
||||
const config = MEMORY_TYPES[type];
|
||||
if (!config) throw new Error(`Unknown memory type: ${type}`);
|
||||
|
||||
const safeSlug = slug.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80);
|
||||
const filePath = join(MEMORY_BASE, config.dir, `${safeSlug}.md`);
|
||||
|
||||
// Build frontmatter
|
||||
const fm = {
|
||||
type,
|
||||
created: new Date().toISOString(),
|
||||
...metadata,
|
||||
};
|
||||
|
||||
// If file exists, update modified time
|
||||
if (existsSync(filePath)) {
|
||||
fm.modified = new Date().toISOString();
|
||||
// Preserve original created date
|
||||
try {
|
||||
const existing = readFileSync(filePath, 'utf8');
|
||||
const createdMatch = existing.match(/^created:\s*(.+)$/m);
|
||||
if (createdMatch) fm.created = createdMatch[1].trim();
|
||||
} catch { /* use current */ }
|
||||
}
|
||||
|
||||
const frontmatter = Object.entries(fm)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n');
|
||||
|
||||
const fileContent = `---\n${frontmatter}\n---\n\n${content}`;
|
||||
writeFileSync(filePath, fileContent);
|
||||
|
||||
// Also sync persistent memories to unified workspace
|
||||
if (config.persist) {
|
||||
try {
|
||||
mkdirSync(UNIFIED_MEMORIES, { recursive: true });
|
||||
writeFileSync(join(UNIFIED_MEMORIES, `agent-${type}-${safeSlug}.md`), fileContent);
|
||||
} catch { /* unified workspace sync is best-effort */ }
|
||||
}
|
||||
|
||||
return { path: filePath, slug: safeSlug, type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a specific memory.
|
||||
*/
|
||||
function readMemory(type, slug) {
|
||||
const config = MEMORY_TYPES[type];
|
||||
if (!config) return null;
|
||||
|
||||
const safeSlug = slug.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const filePath = join(MEMORY_BASE, config.dir, `${safeSlug}.md`);
|
||||
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
return parseMemoryFile(raw, safeSlug, type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all memories of a given type.
|
||||
*/
|
||||
function listMemories(type) {
|
||||
const config = MEMORY_TYPES[type];
|
||||
if (!config) return [];
|
||||
|
||||
const dir = join(MEMORY_BASE, config.dir);
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => {
|
||||
const raw = readFileSync(join(dir, f), 'utf8');
|
||||
return parseMemoryFile(raw, f.replace('.md', ''), type);
|
||||
})
|
||||
.sort((a, b) => (b.modified || b.created || '').localeCompare(a.modified || a.created || ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memories by keyword across all types.
|
||||
* Returns relevance-scored matches.
|
||||
*/
|
||||
function searchMemories(query, opts = {}) {
|
||||
const types = opts.types || Object.keys(MEMORY_TYPES);
|
||||
const maxResults = opts.maxResults || 10;
|
||||
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const type of types) {
|
||||
const memories = listMemories(type);
|
||||
for (const mem of memories) {
|
||||
const text = (mem.title + ' ' + mem.content).toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
for (const term of queryTerms) {
|
||||
const idx = text.indexOf(term);
|
||||
if (idx !== -1) {
|
||||
score += 1;
|
||||
// Bonus for title match
|
||||
if (mem.title.toLowerCase().includes(term)) score += 2;
|
||||
// Bonus for exact phrase match
|
||||
if (text.includes(query.toLowerCase())) score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ ...mem, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a memory.
|
||||
*/
|
||||
function deleteMemory(type, slug) {
|
||||
const config = MEMORY_TYPES[type];
|
||||
if (!config) return false;
|
||||
|
||||
const safeSlug = slug.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const filePath = join(MEMORY_BASE, config.dir, `${safeSlug}.md`);
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ALL memories across all types for system prompt injection.
|
||||
* Returns a formatted string suitable for the AI's context.
|
||||
*/
|
||||
function getMemoryContext(maxTokens = 4000) {
|
||||
const sections = [];
|
||||
let totalChars = 0;
|
||||
const charLimit = maxTokens * 4; // rough char-to-token ratio
|
||||
|
||||
// Priority order: user > project > system > journal (skip session — already in context)
|
||||
for (const type of ['user', 'project', 'system', 'journal']) {
|
||||
const memories = listMemories(type);
|
||||
if (memories.length === 0) continue;
|
||||
|
||||
const typeLabel = MEMORY_TYPES[type].description;
|
||||
let section = `## ${type.charAt(0).toUpperCase() + type.slice(1)} Memories (${typeLabel})\n\n`;
|
||||
|
||||
for (const mem of memories) {
|
||||
const entry = `### ${mem.title}\n${mem.content.slice(0, 500)}\n\n`;
|
||||
if (totalChars + entry.length > charLimit) break;
|
||||
section += entry;
|
||||
totalChars += entry.length;
|
||||
}
|
||||
|
||||
sections.push(section);
|
||||
}
|
||||
|
||||
// Also include key memories from the unified workspace (Copilot memories, etc.)
|
||||
try {
|
||||
if (existsSync(UNIFIED_MEMORIES)) {
|
||||
const unifiedFiles = readdirSync(UNIFIED_MEMORIES)
|
||||
.filter(f => f.endsWith('.md') && !f.startsWith('agent-')) // Skip our own synced files
|
||||
.slice(0, 10);
|
||||
|
||||
if (unifiedFiles.length > 0) {
|
||||
let unifiedSection = '## Inherited Memories (from Copilot, Cursor, GoCodeMe)\n\n';
|
||||
for (const f of unifiedFiles) {
|
||||
const raw = readFileSync(join(UNIFIED_MEMORIES, f), 'utf8');
|
||||
// Take just the first 300 chars of each
|
||||
const preview = raw.replace(/^---[\s\S]*?---\n*/m, '').slice(0, 300);
|
||||
const title = f.replace('.md', '').replace(/-/g, ' ');
|
||||
unifiedSection += `### ${title}\n${preview}\n\n`;
|
||||
totalChars += preview.length + title.length + 10;
|
||||
if (totalChars > charLimit) break;
|
||||
}
|
||||
sections.push(unifiedSection);
|
||||
}
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract memories from a conversation automatically.
|
||||
* This runs in the background after compaction to capture key facts.
|
||||
*
|
||||
* @param {Array} messages - Recent messages to analyze
|
||||
* @param {string} sessionId - Current session ID
|
||||
*/
|
||||
function extractFromConversation(messages, sessionId) {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const extracted = [];
|
||||
|
||||
// Look for explicit memory-write patterns in assistant responses
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'assistant') continue;
|
||||
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.filter(b => b.type === 'text').map(b => b.text).join('\n')
|
||||
: '';
|
||||
|
||||
// Pattern: "I'll remember that..." or "Noted: ..."
|
||||
const rememberPatterns = [
|
||||
/(?:I'll remember|Noted|Recording|Saving to memory)[:\s]+(.{10,200})/gi,
|
||||
/(?:Key fact|Important)[:\s]+(.{10,200})/gi,
|
||||
];
|
||||
|
||||
for (const pattern of rememberPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
extracted.push({
|
||||
type: 'session',
|
||||
content: match[1].trim(),
|
||||
source: 'auto-extract',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save extracted memories
|
||||
for (const mem of extracted) {
|
||||
const slug = `session-${sessionId}-${Date.now()}`;
|
||||
writeMemory(mem.type, slug, mem.content, { source: mem.source, sessionId: mem.sessionId });
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary stats.
|
||||
*/
|
||||
function getStats() {
|
||||
const stats = {};
|
||||
let total = 0;
|
||||
for (const [type, config] of Object.entries(MEMORY_TYPES)) {
|
||||
const dir = join(MEMORY_BASE, config.dir);
|
||||
try {
|
||||
const count = readdirSync(dir).filter(f => f.endsWith('.md')).length;
|
||||
stats[type] = count;
|
||||
total += count;
|
||||
} catch {
|
||||
stats[type] = 0;
|
||||
}
|
||||
}
|
||||
stats.total = total;
|
||||
|
||||
// Count unified workspace memories
|
||||
try {
|
||||
stats.unified = readdirSync(UNIFIED_MEMORIES).filter(f => f.endsWith('.md')).length;
|
||||
} catch {
|
||||
stats.unified = 0;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
return {
|
||||
writeMemory,
|
||||
readMemory,
|
||||
listMemories,
|
||||
searchMemories,
|
||||
deleteMemory,
|
||||
getMemoryContext,
|
||||
extractFromConversation,
|
||||
getStats,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseMemoryFile(raw, slug, type) {
|
||||
let title = slug.replace(/-/g, ' ');
|
||||
let content = raw;
|
||||
let metadata = {};
|
||||
|
||||
// Extract frontmatter
|
||||
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (fmMatch) {
|
||||
const fmLines = fmMatch[1].split('\n');
|
||||
for (const line of fmLines) {
|
||||
const [key, ...valParts] = line.split(':');
|
||||
if (key && valParts.length) {
|
||||
metadata[key.trim()] = valParts.join(':').trim();
|
||||
}
|
||||
}
|
||||
content = fmMatch[2].trim();
|
||||
}
|
||||
|
||||
// Extract title from first heading
|
||||
const headingMatch = content.match(/^#\s+(.+)/m);
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
slug,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
created: metadata.created,
|
||||
modified: metadata.modified,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export { MEMORY_TYPES, MEMORY_BASE, UNIFIED_MEMORIES };
|
||||
250
src/services/messages.js
Normal file
250
src/services/messages.js
Normal file
@ -0,0 +1,250 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Message Type System
|
||||
*
|
||||
* Typed message system for structured conversation management.
|
||||
* Each message has a type that determines how it's handled during
|
||||
* compaction, display, and API serialization.
|
||||
*
|
||||
* Message Types:
|
||||
* - user — User input (text or tool results)
|
||||
* - assistant — Model response (text and/or tool calls)
|
||||
* - system — System-injected context
|
||||
* - compact_boundary — Marks where compaction happened
|
||||
* - tool_summary — Collapsed tool results (from micro-compact)
|
||||
* - attachment — File/context attachments re-injected post-compact
|
||||
* - tombstone — Placeholder for deleted/compacted messages
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Create a user message.
|
||||
* @param {string|Array} content - Text or content blocks
|
||||
* @param {Object} meta - Optional metadata
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createUserMessage(content, meta = {}) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an assistant message.
|
||||
* @param {Array} content - Content blocks (text, tool_use)
|
||||
* @param {Object} meta - Optional metadata (usage, model, etc.)
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createAssistantMessage(content, meta = {}) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a system message (injected context, not sent as API system param).
|
||||
* @param {string} content
|
||||
* @param {string} source - Where this came from (e.g. 'compact', 'skill', 'hook')
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createSystemMessage(content, source = 'system') {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'system',
|
||||
role: 'user', // System context goes as user message to API
|
||||
content,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a compact boundary marker.
|
||||
* Marks where compaction happened — everything before this was summarized.
|
||||
* @param {string} trigger - 'auto' or 'manual'
|
||||
* @param {number} preCompactTokens - Token count before compaction
|
||||
* @param {string} summary - The compaction summary text
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createCompactBoundaryMessage(trigger, preCompactTokens, summary) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'compact_boundary',
|
||||
role: 'user',
|
||||
content: summary,
|
||||
trigger,
|
||||
preCompactTokens,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool use summary (micro-compact collapses tool results into these).
|
||||
* @param {string} toolName
|
||||
* @param {string} summary - Brief summary of what the tool did
|
||||
* @param {string} originalToolUseId - The original tool_use block ID
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createToolSummaryMessage(toolName, summary, originalToolUseId) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'tool_summary',
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: originalToolUseId,
|
||||
content: `[Cached result summary] ${toolName}: ${summary}`,
|
||||
}],
|
||||
originalToolName: toolName,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attachment message (file content, skill output, etc.).
|
||||
* Re-injected after compaction to restore key context.
|
||||
* @param {string} title - Attachment title
|
||||
* @param {string} content - Attachment content
|
||||
* @param {string} attachmentType - 'file', 'skill', 'plan', 'delta'
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createAttachmentMessage(title, content, attachmentType = 'file') {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'attachment',
|
||||
role: 'user',
|
||||
content: `[${attachmentType.toUpperCase()}: ${title}]\n${content}`,
|
||||
attachmentType,
|
||||
title,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tombstone (placeholder for removed messages).
|
||||
* @param {number} removedCount - How many messages were removed
|
||||
* @param {string} reason - Why they were removed
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createTombstoneMessage(removedCount, reason) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'tombstone',
|
||||
role: 'user',
|
||||
content: `[${removedCount} messages removed: ${reason}]`,
|
||||
removedCount,
|
||||
reason,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Utility Functions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a message is a compact boundary.
|
||||
* @param {Object} msg
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCompactBoundary(msg) {
|
||||
return msg?.type === 'compact_boundary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a tombstone.
|
||||
* @param {Object} msg
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTombstone(msg) {
|
||||
return msg?.type === 'tombstone';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is an attachment.
|
||||
* @param {Object} msg
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAttachment(msg) {
|
||||
return msg?.type === 'attachment';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages after the last compact boundary.
|
||||
* @param {Array} messages
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getMessagesAfterLastCompact(messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (isCompactBoundary(messages[i])) {
|
||||
return messages.slice(i);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from an assistant message's content blocks.
|
||||
* @param {Object} msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAssistantText(msg) {
|
||||
if (!msg || msg.role !== 'assistant') return '';
|
||||
if (typeof msg.content === 'string') return msg.content;
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content
|
||||
.filter(b => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool use blocks from an assistant message.
|
||||
* @param {Object} msg
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getToolUseBlocks(msg) {
|
||||
if (!msg || !Array.isArray(msg.content)) return [];
|
||||
return msg.content.filter(b => b.type === 'tool_use');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert typed messages to API format (strips metadata, keeps role/content).
|
||||
* @param {Array} messages
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function toAPIMessages(messages) {
|
||||
return messages
|
||||
.filter(m => !isTombstone(m)) // Skip tombstones
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count messages by type.
|
||||
* @param {Array} messages
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function messageStats(messages) {
|
||||
const stats = {};
|
||||
for (const m of messages) {
|
||||
const type = m.type || m.role;
|
||||
stats[type] = (stats[type] || 0) + 1;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
439
src/services/modelRouter.js
Normal file
439
src/services/modelRouter.js
Normal file
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Model Router Service
|
||||
*
|
||||
* Intelligent model selection and routing with:
|
||||
* - Multiplier-aware token budgets (1x through 600x)
|
||||
* - Context window management per model
|
||||
* - Auto-routing based on task complexity
|
||||
* - Cost-optimized model selection
|
||||
* - Provider fallback chains
|
||||
* - Fast mode (30x default, 600x max)
|
||||
*
|
||||
* Models: Anthropic (Claude), OpenAI (GPT), Groq (Llama/Mixtral)
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
// ── Model Registry ─────────────────────────────────────────────────────
|
||||
|
||||
const MODELS = {
|
||||
// ── Anthropic ──
|
||||
'claude-sonnet-4': {
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
provider: 'anthropic',
|
||||
displayName: 'Claude Sonnet 4',
|
||||
shortName: 'sonnet-4',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
baseMaxTokens: 8192,
|
||||
costPer1MInput: 3,
|
||||
costPer1MOutput: 15,
|
||||
tier: 'standard',
|
||||
capabilities: ['code', 'reasoning', 'analysis', 'creative', 'tools'],
|
||||
speed: 'fast',
|
||||
},
|
||||
'claude-opus-4': {
|
||||
id: 'claude-opus-4-20250514',
|
||||
provider: 'anthropic',
|
||||
displayName: 'Claude Opus 4',
|
||||
shortName: 'opus-4',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 32000,
|
||||
baseMaxTokens: 8192,
|
||||
costPer1MInput: 15,
|
||||
costPer1MOutput: 75,
|
||||
tier: 'premium',
|
||||
capabilities: ['code', 'reasoning', 'analysis', 'creative', 'tools', 'complex-reasoning'],
|
||||
speed: 'moderate',
|
||||
},
|
||||
'claude-haiku-3.5': {
|
||||
id: 'claude-3-5-haiku-20241022',
|
||||
provider: 'anthropic',
|
||||
displayName: 'Claude Haiku 3.5',
|
||||
shortName: 'haiku-3.5',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
baseMaxTokens: 4096,
|
||||
costPer1MInput: 0.8,
|
||||
costPer1MOutput: 4,
|
||||
tier: 'economy',
|
||||
capabilities: ['code', 'tools', 'quick-answers'],
|
||||
speed: 'fastest',
|
||||
},
|
||||
|
||||
// ── OpenAI ──
|
||||
'gpt-4o': {
|
||||
id: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
displayName: 'GPT-4o',
|
||||
shortName: 'gpt-4o',
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
baseMaxTokens: 4096,
|
||||
costPer1MInput: 2.5,
|
||||
costPer1MOutput: 10,
|
||||
tier: 'standard',
|
||||
capabilities: ['code', 'reasoning', 'analysis', 'creative', 'vision', 'tools'],
|
||||
speed: 'fast',
|
||||
},
|
||||
'gpt-4o-mini': {
|
||||
id: 'gpt-4o-mini',
|
||||
provider: 'openai',
|
||||
displayName: 'GPT-4o Mini',
|
||||
shortName: 'gpt-4o-mini',
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
baseMaxTokens: 4096,
|
||||
costPer1MInput: 0.15,
|
||||
costPer1MOutput: 0.6,
|
||||
tier: 'economy',
|
||||
capabilities: ['code', 'tools', 'quick-answers'],
|
||||
speed: 'fastest',
|
||||
},
|
||||
'o1': {
|
||||
id: 'o1',
|
||||
provider: 'openai',
|
||||
displayName: 'o1',
|
||||
shortName: 'o1',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 100000,
|
||||
baseMaxTokens: 8192,
|
||||
costPer1MInput: 15,
|
||||
costPer1MOutput: 60,
|
||||
tier: 'premium',
|
||||
capabilities: ['reasoning', 'complex-reasoning', 'math', 'code'],
|
||||
speed: 'slow',
|
||||
},
|
||||
|
||||
// ── Groq ──
|
||||
'llama-3.3-70b': {
|
||||
id: 'llama-3.3-70b-versatile',
|
||||
provider: 'groq',
|
||||
displayName: 'Llama 3.3 70B',
|
||||
shortName: 'llama-70b',
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 32768,
|
||||
baseMaxTokens: 4096,
|
||||
costPer1MInput: 0.59,
|
||||
costPer1MOutput: 0.79,
|
||||
tier: 'economy',
|
||||
capabilities: ['code', 'reasoning', 'tools'],
|
||||
speed: 'fastest',
|
||||
},
|
||||
'llama-3.1-8b': {
|
||||
id: 'llama-3.1-8b-instant',
|
||||
provider: 'groq',
|
||||
displayName: 'Llama 3.1 8B',
|
||||
shortName: 'llama-8b',
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 8192,
|
||||
baseMaxTokens: 2048,
|
||||
costPer1MInput: 0.05,
|
||||
costPer1MOutput: 0.08,
|
||||
tier: 'free',
|
||||
capabilities: ['code', 'quick-answers'],
|
||||
speed: 'fastest',
|
||||
},
|
||||
'mixtral-8x7b': {
|
||||
id: 'mixtral-8x7b-32768',
|
||||
provider: 'groq',
|
||||
displayName: 'Mixtral 8x7B',
|
||||
shortName: 'mixtral',
|
||||
contextWindow: 32768,
|
||||
maxOutputTokens: 8192,
|
||||
baseMaxTokens: 2048,
|
||||
costPer1MInput: 0.24,
|
||||
costPer1MOutput: 0.24,
|
||||
tier: 'economy',
|
||||
capabilities: ['code', 'quick-answers'],
|
||||
speed: 'fastest',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Multiplier Tiers ───────────────────────────────────────────────────
|
||||
// The multiplier scales max_tokens relative to the model's base.
|
||||
// 1x = base, 30x = 30× base (capped at model maxOutputTokens), etc.
|
||||
|
||||
const MULTIPLIER_TIERS = [
|
||||
{ value: 1, label: '1x', description: 'Minimal — quick answers' },
|
||||
{ value: 30, label: '30x', description: 'Standard — default for Opus 4.6 fast preview' },
|
||||
{ value: 60, label: '60x', description: 'Extended — detailed analysis' },
|
||||
{ value: 120, label: '120x', description: 'Deep — full code generation' },
|
||||
{ value: 300, label: '300x', description: 'Marathon — massive refactors' },
|
||||
{ value: 600, label: '600x', description: 'Maximum — unlimited mode' },
|
||||
];
|
||||
|
||||
// ── Alias Map (from commander extension model select) ──────────────────
|
||||
|
||||
const MODEL_ALIASES = {
|
||||
'sonnet': 'claude-sonnet-4',
|
||||
'sonnet4': 'claude-sonnet-4',
|
||||
'opus': 'claude-opus-4',
|
||||
'opus4': 'claude-opus-4',
|
||||
'haiku': 'claude-haiku-3.5',
|
||||
'gpt4': 'gpt-4o',
|
||||
'gpt4o': 'gpt-4o',
|
||||
'gpt4mini': 'gpt-4o-mini',
|
||||
'mini': 'gpt-4o-mini',
|
||||
'o1': 'o1',
|
||||
'llama': 'llama-3.3-70b',
|
||||
'llama70b': 'llama-3.3-70b',
|
||||
'llama8b': 'llama-3.1-8b',
|
||||
'mixtral': 'mixtral-8x7b',
|
||||
'groq': 'llama-3.3-70b',
|
||||
'auto': null, // Special: auto-route
|
||||
'turbo': 'llama-3.3-70b',
|
||||
};
|
||||
|
||||
// ── Provider Fallback Chains ───────────────────────────────────────────
|
||||
|
||||
const FALLBACK_CHAINS = {
|
||||
anthropic: ['claude-sonnet-4', 'claude-haiku-3.5'],
|
||||
openai: ['gpt-4o', 'gpt-4o-mini'],
|
||||
groq: ['llama-3.3-70b', 'llama-3.1-8b', 'mixtral-8x7b'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a model router instance.
|
||||
*/
|
||||
export function createModelRouter() {
|
||||
let currentModel = 'claude-sonnet-4';
|
||||
let currentMultiplier = 30;
|
||||
let failedProviders = new Set();
|
||||
|
||||
/**
|
||||
* Resolve a model name (alias or full) to a model config.
|
||||
*/
|
||||
function resolveModel(nameOrAlias) {
|
||||
if (!nameOrAlias || nameOrAlias === 'auto') return null;
|
||||
|
||||
// Direct match
|
||||
if (MODELS[nameOrAlias]) return { key: nameOrAlias, ...MODELS[nameOrAlias] };
|
||||
|
||||
// Alias match
|
||||
const aliasKey = MODEL_ALIASES[nameOrAlias.toLowerCase()];
|
||||
if (aliasKey && MODELS[aliasKey]) return { key: aliasKey, ...MODELS[aliasKey] };
|
||||
|
||||
// Partial match (model ID)
|
||||
for (const [key, config] of Object.entries(MODELS)) {
|
||||
if (config.id === nameOrAlias || config.shortName === nameOrAlias) {
|
||||
return { key, ...config };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate effective max_tokens based on multiplier.
|
||||
* This is the core multiplier calculation that must match the IDE's behavior.
|
||||
*
|
||||
* Formula: min(baseMaxTokens × multiplier, maxOutputTokens)
|
||||
*
|
||||
* Examples with claude-sonnet-4 (base=8192, max=16384):
|
||||
* 1x → min(8192 × 1, 16384) = 8,192
|
||||
* 30x → min(8192 × 30, 16384) = 16,384 (capped)
|
||||
*
|
||||
* Examples with claude-opus-4 (base=8192, max=32000):
|
||||
* 1x → 8,192
|
||||
* 30x → 32,000 (capped)
|
||||
* 600x → 32,000 (capped)
|
||||
*/
|
||||
function calculateMaxTokens(modelKey, multiplier) {
|
||||
const model = MODELS[modelKey] || MODELS[currentModel];
|
||||
if (!model) return 8192;
|
||||
|
||||
const effective = Math.min(
|
||||
model.baseMaxTokens * (multiplier || currentMultiplier),
|
||||
model.maxOutputTokens,
|
||||
);
|
||||
return Math.max(256, effective);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-route: pick the best model for a task based on complexity signals.
|
||||
*/
|
||||
function autoRoute(message, options = {}) {
|
||||
const msg = (typeof message === 'string' ? message : '').toLowerCase();
|
||||
const len = msg.length;
|
||||
|
||||
// Skip failed providers
|
||||
const available = Object.entries(MODELS).filter(
|
||||
([, config]) => !failedProviders.has(config.provider),
|
||||
);
|
||||
|
||||
if (available.length === 0) {
|
||||
failedProviders.clear(); // Reset and try again
|
||||
}
|
||||
|
||||
// Complexity signals
|
||||
const isComplex =
|
||||
len > 2000 ||
|
||||
/\b(refactor|architect|design|implement|build|create|debug|analyze)\b/i.test(msg) ||
|
||||
/\b(entire|whole|complete|full|all)\b/i.test(msg) ||
|
||||
msg.includes('```');
|
||||
|
||||
const isSimple =
|
||||
len < 100 &&
|
||||
/\b(what|how|why|where|when|which|is|are|can|do|does|hi|hello|thanks)\b/i.test(msg) &&
|
||||
!isComplex;
|
||||
|
||||
const needsReasoning =
|
||||
/\b(explain|reason|think|consider|compare|evaluate|trade-?off|pros?\s*(and|&)\s*cons?)\b/i.test(msg);
|
||||
|
||||
// Cost preference
|
||||
const preferCheap = options.optimizeCost || false;
|
||||
|
||||
if (isSimple && !needsReasoning) {
|
||||
// Quick answers → cheapest fast model
|
||||
if (!failedProviders.has('groq')) return resolveModel('llama-3.3-70b');
|
||||
return resolveModel('gpt-4o-mini');
|
||||
}
|
||||
|
||||
if (needsReasoning && !preferCheap) {
|
||||
// Complex reasoning → premium model
|
||||
if (!failedProviders.has('anthropic')) return resolveModel('claude-opus-4');
|
||||
return resolveModel('o1');
|
||||
}
|
||||
|
||||
if (isComplex && !preferCheap) {
|
||||
// Standard complex work → Sonnet
|
||||
if (!failedProviders.has('anthropic')) return resolveModel('claude-sonnet-4');
|
||||
return resolveModel('gpt-4o');
|
||||
}
|
||||
|
||||
// Default: Sonnet
|
||||
if (!failedProviders.has('anthropic')) return resolveModel('claude-sonnet-4');
|
||||
if (!failedProviders.has('openai')) return resolveModel('gpt-4o');
|
||||
return resolveModel('llama-3.3-70b');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a provider failure for fallback routing.
|
||||
*/
|
||||
function recordProviderFailure(provider) {
|
||||
failedProviders.add(provider);
|
||||
// Auto-clear after 5 minutes
|
||||
setTimeout(() => failedProviders.delete(provider), 300000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback model for a given model.
|
||||
*/
|
||||
function getFallback(modelKey) {
|
||||
const model = MODELS[modelKey];
|
||||
if (!model) return null;
|
||||
|
||||
const chain = FALLBACK_CHAINS[model.provider] || [];
|
||||
const idx = chain.indexOf(modelKey);
|
||||
const nextKey = chain[idx + 1];
|
||||
|
||||
if (nextKey) return resolveModel(nextKey);
|
||||
|
||||
// Cross-provider fallback
|
||||
const otherProviders = Object.keys(FALLBACK_CHAINS).filter(p => p !== model.provider && !failedProviders.has(p));
|
||||
for (const p of otherProviders) {
|
||||
return resolveModel(FALLBACK_CHAINS[p][0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active model.
|
||||
*/
|
||||
function setModel(nameOrAlias) {
|
||||
const resolved = resolveModel(nameOrAlias);
|
||||
if (resolved) {
|
||||
currentModel = resolved.key;
|
||||
return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the multiplier (with validation).
|
||||
*/
|
||||
function setMultiplier(value) {
|
||||
const v = parseInt(value, 10);
|
||||
if (v >= 1 && v <= 600) {
|
||||
currentMultiplier = v;
|
||||
return v;
|
||||
}
|
||||
return currentMultiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full routing config for a request.
|
||||
* This is what the agent uses to configure its API call.
|
||||
*/
|
||||
function getRouteConfig(modelName, multiplier, message) {
|
||||
let model;
|
||||
|
||||
if (!modelName || modelName === 'auto') {
|
||||
model = autoRoute(message);
|
||||
} else {
|
||||
model = resolveModel(modelName);
|
||||
}
|
||||
|
||||
if (!model) model = resolveModel(currentModel);
|
||||
const effectiveMultiplier = multiplier || currentMultiplier;
|
||||
const maxTokens = calculateMaxTokens(model.key, effectiveMultiplier);
|
||||
|
||||
return {
|
||||
model: model.id,
|
||||
modelKey: model.key,
|
||||
provider: model.provider,
|
||||
displayName: model.displayName,
|
||||
shortName: model.shortName,
|
||||
maxTokens,
|
||||
contextWindow: model.contextWindow,
|
||||
multiplier: effectiveMultiplier,
|
||||
multiplierLabel: `${effectiveMultiplier}x`,
|
||||
costPer1MInput: model.costPer1MInput,
|
||||
costPer1MOutput: model.costPer1MOutput,
|
||||
tier: model.tier,
|
||||
speed: model.speed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available models.
|
||||
*/
|
||||
function listModels() {
|
||||
return Object.entries(MODELS).map(([key, config]) => ({
|
||||
key,
|
||||
...config,
|
||||
isCurrent: key === currentModel,
|
||||
available: !failedProviders.has(config.provider),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state.
|
||||
*/
|
||||
function getState() {
|
||||
return {
|
||||
currentModel,
|
||||
currentMultiplier,
|
||||
failedProviders: [...failedProviders],
|
||||
route: getRouteConfig(currentModel, currentMultiplier),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
resolveModel,
|
||||
calculateMaxTokens,
|
||||
autoRoute,
|
||||
recordProviderFailure,
|
||||
getFallback,
|
||||
setModel,
|
||||
setMultiplier,
|
||||
getRouteConfig,
|
||||
listModels,
|
||||
getState,
|
||||
};
|
||||
}
|
||||
|
||||
export { MODELS, MULTIPLIER_TIERS, MODEL_ALIASES, FALLBACK_CHAINS };
|
||||
356
src/services/permissions.js
Normal file
356
src/services/permissions.js
Normal file
@ -0,0 +1,356 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Permissions & Approval Flow Service
|
||||
*
|
||||
* Tool-level access control with:
|
||||
* - Commander (client_id=33): unrestricted, all tools allowed
|
||||
* - Customer tier: sandboxed, dangerous ops require approval
|
||||
* - Future: per-user rules, time-limited grants
|
||||
*
|
||||
* Pattern: preToolUse hook checks → allow/deny/ask
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const PERMISSIONS_DIR = join(process.env.HOME || '/tmp', 'alfred-agent', 'data', 'permissions');
|
||||
|
||||
// ── Default Permission Profiles ────────────────────────────────────────
|
||||
|
||||
const PROFILES = {
|
||||
commander: {
|
||||
name: 'Commander',
|
||||
description: 'Full unrestricted access — Commander Danny (client_id=33)',
|
||||
allowAll: true,
|
||||
maxMultiplier: 600,
|
||||
maxTokensPerQuery: 128000,
|
||||
canApproveOthers: true,
|
||||
canAccessVault: true,
|
||||
canModifySystem: true,
|
||||
canDeleteFiles: true,
|
||||
canRunBash: true,
|
||||
canRunDestructive: true,
|
||||
canAccessAllDomains: true,
|
||||
},
|
||||
|
||||
customer: {
|
||||
name: 'Customer',
|
||||
description: 'Sandboxed access — paying customer with workspace',
|
||||
allowAll: false,
|
||||
maxMultiplier: 120,
|
||||
maxTokensPerQuery: 32000,
|
||||
canApproveOthers: false,
|
||||
canAccessVault: false,
|
||||
canModifySystem: false,
|
||||
canDeleteFiles: false, // Must approve each delete
|
||||
canRunBash: true, // Sandboxed bash only
|
||||
canRunDestructive: false,
|
||||
canAccessAllDomains: false,
|
||||
allowedTools: [
|
||||
'read_file', 'write_file', 'edit_file', 'list_dir',
|
||||
'bash', 'search', 'web_fetch', 'mcp_call',
|
||||
'agent', 'task_create', 'task_update', 'task_list',
|
||||
],
|
||||
blockedTools: [
|
||||
'vault_read', 'vault_write', 'system_config',
|
||||
'pm2_control', 'database_admin',
|
||||
],
|
||||
// Bash command restrictions for customer profile
|
||||
bashAllowPatterns: [
|
||||
/^(ls|cat|head|tail|wc|grep|find|echo|pwd|date|whoami|node|npm|python3?|php|git)\b/,
|
||||
],
|
||||
bashBlockPatterns: [
|
||||
/\brm\s+-rf?\s+\//, // No rm -r /
|
||||
/\bsudo\b/, // No sudo
|
||||
/\bchmod\s+[0-7]*7/, // No world-writable
|
||||
/\bkill\b/, // No kill
|
||||
/\bpkill\b/,
|
||||
/\bsystemctl\b/,
|
||||
/\biptables\b/,
|
||||
/\bcrontab\b/,
|
||||
/\bcurl.*\|\s*bash/, // No curl|bash
|
||||
/\bwget.*\|\s*bash/,
|
||||
/\bdd\s+if=/, // No dd
|
||||
/\bmkfs\b/,
|
||||
],
|
||||
},
|
||||
|
||||
free: {
|
||||
name: 'Free Tier',
|
||||
description: 'Limited access — free account',
|
||||
allowAll: false,
|
||||
maxMultiplier: 30,
|
||||
maxTokensPerQuery: 8192,
|
||||
canApproveOthers: false,
|
||||
canAccessVault: false,
|
||||
canModifySystem: false,
|
||||
canDeleteFiles: false,
|
||||
canRunBash: false,
|
||||
canRunDestructive: false,
|
||||
canAccessAllDomains: false,
|
||||
allowedTools: [
|
||||
'read_file', 'write_file', 'edit_file', 'list_dir',
|
||||
'search', 'web_fetch',
|
||||
],
|
||||
blockedTools: [
|
||||
'bash', 'mcp_call', 'vault_read', 'vault_write',
|
||||
'system_config', 'pm2_control', 'database_admin',
|
||||
'agent', 'task_create',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-User Rule Overrides ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load per-user permission overrides from disk.
|
||||
*/
|
||||
function loadUserRules(clientId) {
|
||||
try {
|
||||
const filePath = join(PERMISSIONS_DIR, `user-${clientId}.json`);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save per-user permission overrides.
|
||||
*/
|
||||
function saveUserRules(clientId, rules) {
|
||||
mkdirSync(PERMISSIONS_DIR, { recursive: true });
|
||||
const filePath = join(PERMISSIONS_DIR, `user-${clientId}.json`);
|
||||
writeFileSync(filePath, JSON.stringify(rules, null, 2));
|
||||
}
|
||||
|
||||
// ── Permission Engine ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a permission engine for a user session.
|
||||
*
|
||||
* @param {string} profile - 'commander' | 'customer' | 'free'
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.clientId
|
||||
* @param {string} opts.workspaceRoot - Sandbox root path
|
||||
* @param {Function} opts.onApprovalNeeded - Called when user approval is needed
|
||||
* Returns Promise<boolean> (true = approve, false = deny)
|
||||
*/
|
||||
export function createPermissionEngine(profile = 'commander', opts = {}) {
|
||||
const baseProfile = PROFILES[profile] || PROFILES.customer;
|
||||
const userRules = opts.clientId ? loadUserRules(opts.clientId) : null;
|
||||
const workspaceRoot = opts.workspaceRoot || process.cwd();
|
||||
const onApprovalNeeded = opts.onApprovalNeeded || (() => Promise.resolve(false));
|
||||
|
||||
// Merge user overrides onto base profile
|
||||
const effectiveProfile = { ...baseProfile };
|
||||
if (userRules) {
|
||||
if (userRules.additionalTools) {
|
||||
effectiveProfile.allowedTools = [
|
||||
...(effectiveProfile.allowedTools || []),
|
||||
...userRules.additionalTools,
|
||||
];
|
||||
}
|
||||
if (userRules.maxMultiplier !== undefined) {
|
||||
effectiveProfile.maxMultiplier = Math.min(
|
||||
userRules.maxMultiplier,
|
||||
baseProfile.maxMultiplier,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Approval log
|
||||
const approvalLog = [];
|
||||
|
||||
/**
|
||||
* Check if a tool call is permitted.
|
||||
* Returns: { allowed: boolean, reason?: string, needsApproval?: boolean }
|
||||
*/
|
||||
function checkToolPermission(toolName, input = {}) {
|
||||
// Commander gets everything
|
||||
if (effectiveProfile.allowAll) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Explicitly blocked tools
|
||||
if (effectiveProfile.blockedTools?.includes(toolName)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Tool "${toolName}" is blocked for ${effectiveProfile.name} profile`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if tool is in allowed list
|
||||
if (effectiveProfile.allowedTools && !effectiveProfile.allowedTools.includes(toolName)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Tool "${toolName}" is not in the allowed list for ${effectiveProfile.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Special checks for bash commands
|
||||
if (toolName === 'bash' && input.command) {
|
||||
return checkBashPermission(input.command);
|
||||
}
|
||||
|
||||
// File path sandboxing
|
||||
if (['read_file', 'write_file', 'edit_file'].includes(toolName) && input.path) {
|
||||
return checkPathPermission(input.path);
|
||||
}
|
||||
|
||||
// Delete operations need approval
|
||||
if (toolName === 'write_file' && input.path && !effectiveProfile.canDeleteFiles) {
|
||||
// Writing empty content = effective delete
|
||||
if (!input.content || input.content.trim() === '') {
|
||||
return {
|
||||
allowed: false,
|
||||
needsApproval: true,
|
||||
reason: `Deleting files requires approval for ${effectiveProfile.name}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bash command permission.
|
||||
*/
|
||||
function checkBashPermission(command) {
|
||||
if (!effectiveProfile.canRunBash) {
|
||||
return { allowed: false, reason: 'Bash access is not available on your plan' };
|
||||
}
|
||||
|
||||
// Check block patterns
|
||||
if (effectiveProfile.bashBlockPatterns) {
|
||||
for (const pattern of effectiveProfile.bashBlockPatterns) {
|
||||
if (pattern.test(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Command matches blocked pattern: ${pattern}`,
|
||||
needsApproval: effectiveProfile.name === 'Customer',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file path permission (sandboxing).
|
||||
*/
|
||||
function checkPathPermission(filePath) {
|
||||
if (effectiveProfile.canAccessAllDomains) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Must be under workspace root
|
||||
const resolved = require('path').resolve(filePath);
|
||||
if (!resolved.startsWith(workspaceRoot)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Access denied: ${filePath} is outside your workspace (${workspaceRoot})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Block access to sensitive files
|
||||
const sensitivePatterns = [
|
||||
/\.env$/,
|
||||
/credentials/i,
|
||||
/\.key$/,
|
||||
/\.pem$/,
|
||||
/password/i,
|
||||
/secret/i,
|
||||
/\.htaccess$/,
|
||||
];
|
||||
|
||||
for (const pattern of sensitivePatterns) {
|
||||
if (pattern.test(filePath)) {
|
||||
return {
|
||||
allowed: false,
|
||||
needsApproval: true,
|
||||
reason: `Accessing sensitive file requires approval: ${filePath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiplier against profile limits.
|
||||
* Returns clamped multiplier.
|
||||
*/
|
||||
function clampMultiplier(requestedMultiplier) {
|
||||
const max = effectiveProfile.maxMultiplier || 30;
|
||||
return Math.min(Math.max(1, requestedMultiplier || 30), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate max tokens against profile limits.
|
||||
*/
|
||||
function clampMaxTokens(requestedTokens) {
|
||||
const max = effectiveProfile.maxTokensPerQuery || 8192;
|
||||
return Math.min(Math.max(256, requestedTokens || 8192), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request approval for a blocked action.
|
||||
* Records the result for audit.
|
||||
*/
|
||||
async function requestApproval(toolName, input, reason) {
|
||||
const request = {
|
||||
ts: Date.now(),
|
||||
toolName,
|
||||
input: JSON.stringify(input).slice(0, 500),
|
||||
reason,
|
||||
profile: effectiveProfile.name,
|
||||
clientId: opts.clientId,
|
||||
};
|
||||
|
||||
try {
|
||||
const approved = await onApprovalNeeded(request);
|
||||
request.result = approved ? 'approved' : 'denied';
|
||||
approvalLog.push(request);
|
||||
return approved;
|
||||
} catch {
|
||||
request.result = 'error';
|
||||
approvalLog.push(request);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective profile for inspection.
|
||||
*/
|
||||
function getProfile() {
|
||||
return {
|
||||
...effectiveProfile,
|
||||
// Don't expose regex patterns in JSON
|
||||
bashAllowPatterns: undefined,
|
||||
bashBlockPatterns: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the approval audit log.
|
||||
*/
|
||||
function getApprovalLog() {
|
||||
return [...approvalLog];
|
||||
}
|
||||
|
||||
return {
|
||||
checkToolPermission,
|
||||
clampMultiplier,
|
||||
clampMaxTokens,
|
||||
requestApproval,
|
||||
getProfile,
|
||||
getApprovalLog,
|
||||
saveUserRules: (rules) => saveUserRules(opts.clientId, rules),
|
||||
};
|
||||
}
|
||||
|
||||
export { PROFILES, loadUserRules, saveUserRules };
|
||||
173
src/services/redact.js
Normal file
173
src/services/redact.js
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED BRAIN — Secret Redaction Engine (Node.js)
|
||||
*
|
||||
* Single-pass multi-pattern redaction for all tool output before it
|
||||
* reaches the LLM context window. Prevents credential leaks in
|
||||
* agent conversations.
|
||||
*
|
||||
* Pattern: Omahon redact.rs (Aho-Corasick style, longest-first)
|
||||
* Loads secrets from:
|
||||
* 1. Vault key files in tmpfs (/run/user/1004/keys/)
|
||||
* 2. Environment variables (ANTHROPIC_API_KEY, etc.)
|
||||
* 3. Vault master key on disk
|
||||
*
|
||||
* Does NOT shell out to PHP — all in-process for speed.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const MIN_REDACT_LEN = 8;
|
||||
|
||||
/** Well-known env vars that may contain secrets */
|
||||
const SECRET_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'GROQ_API_KEY', 'XAI_API_KEY', 'MISTRAL_API_KEY',
|
||||
'CEREBRAS_API_KEY', 'TOGETHER_API_KEY',
|
||||
'BRAVE_API_KEY', 'TAVILY_API_KEY',
|
||||
'GITHUB_TOKEN', 'GH_TOKEN',
|
||||
'AWS_SECRET_ACCESS_KEY', 'NPM_TOKEN', 'DOCKER_PASSWORD',
|
||||
'DISCORD_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'STRIPE_SECRET_KEY', 'STRIPE_LIVE_KEY',
|
||||
'INTERNAL_SECRET',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a redactor instance.
|
||||
* Call load() to populate patterns, then redact(text) on every tool result.
|
||||
*/
|
||||
export function createRedactor() {
|
||||
/** @type {Array<{pattern: string, replacement: string}>} sorted by length DESC */
|
||||
let patterns = [];
|
||||
let loaded = false;
|
||||
|
||||
/**
|
||||
* Load secrets from all available sources.
|
||||
*/
|
||||
function load() {
|
||||
const secrets = new Map(); // name → value
|
||||
|
||||
// 1. Vault key files in tmpfs
|
||||
const keyDirs = [
|
||||
'/run/user/1004/keys',
|
||||
join(process.env.HOME || '/tmp', '.vault', 'keys'),
|
||||
];
|
||||
for (const keyDir of keyDirs) {
|
||||
try {
|
||||
const files = readdirSync(keyDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.key')) continue;
|
||||
try {
|
||||
const val = readFileSync(join(keyDir, file), 'utf8').trim();
|
||||
if (val.length >= MIN_REDACT_LEN) {
|
||||
const name = file.replace('.key', '').toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
||||
secrets.set(name, val);
|
||||
}
|
||||
} catch { /* skip unreadable */ }
|
||||
}
|
||||
} catch { /* dir may not exist */ }
|
||||
}
|
||||
|
||||
// 2. Environment variables
|
||||
for (const envVar of SECRET_ENV_VARS) {
|
||||
const val = process.env[envVar];
|
||||
if (val && val.length >= MIN_REDACT_LEN) {
|
||||
secrets.set(envVar, val);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vault master key
|
||||
const vaultKeyPath = join(process.env.HOME || '/tmp', '.vault-master-key');
|
||||
try {
|
||||
const vaultKey = readFileSync(vaultKeyPath, 'utf8').trim();
|
||||
if (vaultKey.length >= MIN_REDACT_LEN) {
|
||||
secrets.set('VAULT_MASTER_KEY', vaultKey);
|
||||
}
|
||||
} catch { /* file may not exist */ }
|
||||
|
||||
// 4. Sort by value length DESC (longest match wins — prevents partial matches)
|
||||
patterns = [];
|
||||
for (const [name, value] of secrets) {
|
||||
patterns.push({ pattern: value, replacement: `[REDACTED:${name}]` });
|
||||
}
|
||||
patterns.sort((a, b) => b.pattern.length - a.pattern.length);
|
||||
|
||||
loaded = true;
|
||||
return patterns.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact all known secrets from a string.
|
||||
* @param {string} text - Text to scrub
|
||||
* @returns {string} Cleaned text
|
||||
*/
|
||||
function redact(text) {
|
||||
if (!loaded || patterns.length === 0 || !text) return text;
|
||||
let output = text;
|
||||
for (const { pattern, replacement } of patterns) {
|
||||
// Use split+join for safe replacement (no regex special chars issues)
|
||||
if (output.includes(pattern)) {
|
||||
output = output.split(pattern).join(replacement);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact a tool result object (handles string content and nested JSON).
|
||||
* @param {*} result - Tool result (string or object)
|
||||
* @returns {*} Redacted result
|
||||
*/
|
||||
function redactToolResult(result) {
|
||||
if (typeof result === 'string') return redact(result);
|
||||
if (result === null || result === undefined) return result;
|
||||
if (typeof result !== 'object') return result;
|
||||
|
||||
// Deep clone and redact all string values
|
||||
const json = JSON.stringify(result);
|
||||
const redacted = redact(json);
|
||||
try {
|
||||
return JSON.parse(redacted);
|
||||
} catch {
|
||||
return redacted; // fallback to string if parse fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a secret dynamically (e.g., from vault decryption at runtime).
|
||||
*/
|
||||
function addSecret(name, value) {
|
||||
if (!value || value.length < MIN_REDACT_LEN) return;
|
||||
// Remove existing entry for this name
|
||||
patterns = patterns.filter(p => p.replacement !== `[REDACTED:${name}]`);
|
||||
patterns.push({ pattern: value, replacement: `[REDACTED:${name}]` });
|
||||
patterns.sort((a, b) => b.pattern.length - a.pattern.length);
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
redact,
|
||||
redactToolResult,
|
||||
addSecret,
|
||||
isLoaded: () => loaded,
|
||||
patternCount: () => patterns.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton for the agent process
|
||||
let _globalRedactor = null;
|
||||
|
||||
/**
|
||||
* Get or create the global redactor instance.
|
||||
* Auto-loads secrets on first call.
|
||||
*/
|
||||
export function getRedactor() {
|
||||
if (!_globalRedactor) {
|
||||
_globalRedactor = createRedactor();
|
||||
_globalRedactor.load();
|
||||
}
|
||||
return _globalRedactor;
|
||||
}
|
||||
289
src/services/skillEngine.js
Normal file
289
src/services/skillEngine.js
Normal file
@ -0,0 +1,289 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Skills Engine
|
||||
*
|
||||
* Extensible skill system that lets Alfred learn new capabilities
|
||||
* through SKILL.md files. Skills are self-contained instruction sets
|
||||
* with metadata, triggers, and allowed tools.
|
||||
*
|
||||
* SKILL.md Format:
|
||||
* ---
|
||||
* name: skill-name
|
||||
* description: What this skill does
|
||||
* when_to_use: When to auto-invoke this skill
|
||||
* allowed_tools: [bash, read_file, write_file]
|
||||
* arguments:
|
||||
* - name: target
|
||||
* description: Build target
|
||||
* required: true
|
||||
* ---
|
||||
*
|
||||
* # Skill Content
|
||||
* Instructions for the AI to follow when this skill is invoked.
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const HOME = homedir();
|
||||
const SKILLS_DIRS = [
|
||||
join(HOME, 'alfred-agent', 'data', 'skills'), // User skills
|
||||
join(HOME, 'alfred-agent', 'skills'), // Bundled skills
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse SKILL.md frontmatter (YAML-like) and content.
|
||||
* @param {string} raw - Raw file content
|
||||
* @returns {{ meta: Object, content: string }}
|
||||
*/
|
||||
function parseSkillFile(raw) {
|
||||
const meta = {};
|
||||
let content = raw;
|
||||
|
||||
// Check for YAML frontmatter
|
||||
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (fmMatch) {
|
||||
const yamlBlock = fmMatch[1];
|
||||
content = fmMatch[2].trim();
|
||||
|
||||
// Simple YAML parser for flat and list values
|
||||
for (const line of yamlBlock.split('\n')) {
|
||||
const kvMatch = line.match(/^(\w[\w_-]*)\s*:\s*(.+)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1].trim();
|
||||
let value = kvMatch[2].trim();
|
||||
|
||||
// Handle inline arrays: [a, b, c]
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
// Handle booleans
|
||||
else if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
// Remove quotes
|
||||
else value = value.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
meta[key] = value;
|
||||
}
|
||||
// Handle YAML list items: - item
|
||||
const listMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
if (listMatch) {
|
||||
// Find the most recently set key and append
|
||||
const lastKey = Object.keys(meta).pop();
|
||||
if (lastKey && !Array.isArray(meta[lastKey])) {
|
||||
meta[lastKey] = [meta[lastKey]];
|
||||
}
|
||||
if (lastKey) {
|
||||
meta[lastKey].push(listMatch[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { meta, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills from disk.
|
||||
* @returns {Array<Object>} Array of skill objects
|
||||
*/
|
||||
export function loadSkills() {
|
||||
const skills = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const dir of SKILLS_DIRS) {
|
||||
if (!existsSync(dir)) continue;
|
||||
|
||||
const files = readdirSync(dir).filter(f =>
|
||||
f.endsWith('.md') || f.endsWith('.skill.md') || f === 'SKILL.md'
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = readFileSync(join(dir, file), 'utf8');
|
||||
const { meta, content } = parseSkillFile(raw);
|
||||
|
||||
const name = meta.name || file.replace(/\.(skill\.)?md$/, '');
|
||||
if (seen.has(name)) continue; // User skills shadow bundled
|
||||
seen.add(name);
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: meta.description || '',
|
||||
when_to_use: meta.when_to_use || '',
|
||||
allowed_tools: Array.isArray(meta.allowed_tools) ? meta.allowed_tools : [],
|
||||
arguments: Array.isArray(meta.arguments) ? meta.arguments : [],
|
||||
content,
|
||||
file: join(dir, file),
|
||||
source: dir.includes('data/skills') ? 'user' : 'bundled',
|
||||
});
|
||||
} catch { /* skip unreadable skill files */ }
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a user message against skill triggers.
|
||||
* Returns skills that should be auto-invoked.
|
||||
* @param {string} userMessage
|
||||
* @param {Array} skills
|
||||
* @returns {Array} Matching skills
|
||||
*/
|
||||
export function matchSkills(userMessage, skills) {
|
||||
if (!userMessage || !skills.length) return [];
|
||||
|
||||
const lower = userMessage.toLowerCase();
|
||||
const matched = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
// Check when_to_use trigger
|
||||
if (skill.when_to_use) {
|
||||
const trigger = skill.when_to_use.toLowerCase();
|
||||
|
||||
// Simple keyword matching — check if trigger words appear in user message
|
||||
const triggerWords = trigger.split(/[,;|]/).map(w => w.trim()).filter(Boolean);
|
||||
const isTriggered = triggerWords.some(tw => {
|
||||
// Support glob-like patterns: "build*", "*deploy*"
|
||||
if (tw.includes('*')) {
|
||||
const regex = new RegExp(tw.replace(/\*/g, '.*'), 'i');
|
||||
return regex.test(lower);
|
||||
}
|
||||
return lower.includes(tw);
|
||||
});
|
||||
|
||||
if (isTriggered) {
|
||||
matched.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
// Also match by skill name mention
|
||||
if (lower.includes(skill.name.toLowerCase())) {
|
||||
if (!matched.includes(skill)) matched.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill invocation prompt to inject into the system prompt.
|
||||
* @param {Object} skill
|
||||
* @param {Object} args - Resolved arguments for the skill
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSkillPrompt(skill, args = {}) {
|
||||
let prompt = `# Active Skill: ${skill.name}\n`;
|
||||
|
||||
if (skill.description) {
|
||||
prompt += `${skill.description}\n\n`;
|
||||
}
|
||||
|
||||
if (skill.allowed_tools.length > 0) {
|
||||
prompt += `Allowed tools for this skill: ${skill.allowed_tools.join(', ')}\n\n`;
|
||||
}
|
||||
|
||||
// Substitute arguments in content
|
||||
let content = skill.content;
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
}
|
||||
|
||||
prompt += content;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skill listing section for the system prompt.
|
||||
* Brief descriptions of all available skills.
|
||||
* @param {Array} skills
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSkillListing(skills) {
|
||||
if (!skills || skills.length === 0) return '';
|
||||
|
||||
const lines = skills.map(s =>
|
||||
` - **${s.name}**: ${s.description || 'No description'}${s.when_to_use ? ` (triggers: ${s.when_to_use})` : ''}`
|
||||
);
|
||||
|
||||
return `# Available Skills
|
||||
|
||||
You have ${skills.length} skills available. Skills are invoked automatically when their triggers match, or you can invoke them explicitly.
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
To use a skill not auto-invoked, tell the user about it or use the relevant tools directly.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the skills engine — manages loading, matching, and invocation.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createSkillEngine() {
|
||||
let skills = loadSkills();
|
||||
const invokedSkills = new Set();
|
||||
|
||||
return {
|
||||
/**
|
||||
* Get all loaded skills.
|
||||
* @returns {Array}
|
||||
*/
|
||||
getSkills() { return skills; },
|
||||
|
||||
/**
|
||||
* Reload skills from disk.
|
||||
*/
|
||||
reload() { skills = loadSkills(); },
|
||||
|
||||
/**
|
||||
* Match skills for a user message.
|
||||
* @param {string} userMessage
|
||||
* @returns {Array}
|
||||
*/
|
||||
match(userMessage) {
|
||||
return matchSkills(userMessage, skills);
|
||||
},
|
||||
|
||||
/**
|
||||
* Record that a skill was invoked this session.
|
||||
* @param {string} skillName
|
||||
*/
|
||||
markInvoked(skillName) {
|
||||
invokedSkills.add(skillName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get skills that were invoked this session (for post-compact restoration).
|
||||
* @returns {Array}
|
||||
*/
|
||||
getInvokedSkills() {
|
||||
return skills.filter(s => invokedSkills.has(s.name));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get skill listing for system prompt.
|
||||
* @returns {string}
|
||||
*/
|
||||
getListing() {
|
||||
return getSkillListing(skills);
|
||||
},
|
||||
|
||||
/**
|
||||
* Build system prompt section for active skills.
|
||||
* @param {string} userMessage
|
||||
* @param {Object} args
|
||||
* @returns {string[]} Prompt sections for matched skills
|
||||
*/
|
||||
getActiveSkillPrompts(userMessage, args = {}) {
|
||||
const matched = this.match(userMessage);
|
||||
return matched.map(skill => {
|
||||
this.markInvoked(skill.name);
|
||||
return buildSkillPrompt(skill, args);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
197
src/services/steering.js
Normal file
197
src/services/steering.js
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Steering Prompt System
|
||||
*
|
||||
* Layered, context-aware steering that guides AI behavior across
|
||||
* tool execution, safety boundaries, and quality standards.
|
||||
*
|
||||
* Steering layers:
|
||||
* 1. Tool-specific prompts — embedded in each tool definition
|
||||
* 2. Git safety rules — never force-push, never skip hooks, etc.
|
||||
* 3. Security boundaries — OWASP, credential handling, SSRF protection
|
||||
* 4. Output quality — formatting, conciseness, file references
|
||||
* 5. Session continuity — memory, context awareness, task tracking
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TOOL-SPECIFIC STEERING PROMPTS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const TOOL_STEERING = {
|
||||
bash: `## Bash Tool Guidelines
|
||||
- NEVER run commands with \`--no-verify\`, \`--force\`, or \`-f\` on git operations without explicit Commander approval
|
||||
- NEVER run \`git push --force\`, \`git reset --hard\`, or \`git rebase\` on shared branches
|
||||
- NEVER run \`rm -rf\` on paths you haven't verified
|
||||
- NEVER pipe untrusted input to \`bash -c\` or \`eval\`
|
||||
- Always check exit codes — non-zero means something went wrong
|
||||
- Use \`set -e\` for multi-step commands when first failure should abort
|
||||
- Prefer \`&&\` chaining over \`;\` so failures propagate
|
||||
- Redirect stderr: use \`2>&1\` when you need to see errors
|
||||
- Cap output: pipe through \`head -100\` or \`tail -50\` for potentially large output
|
||||
- For long-running commands, set a reasonable timeout`,
|
||||
|
||||
read_file: `## File Read Guidelines
|
||||
- Read BEFORE modifying — always understand existing code first
|
||||
- Read enough context — at least the full function, not just the target lines
|
||||
- Prefer reading a large range once over many small reads
|
||||
- When reading config files, read the whole file — they're usually small`,
|
||||
|
||||
write_file: `## File Write Guidelines
|
||||
- NEVER overwrite files without reading them first (unless creating new)
|
||||
- Always create parent directories (handled by tool, but be aware)
|
||||
- For existing files, prefer edit_file over write_file to avoid losing content
|
||||
- Check that content is complete — don't write partial files`,
|
||||
|
||||
edit_file: `## File Edit Guidelines
|
||||
- Include enough context in oldString (at least 3 lines before and after)
|
||||
- oldString must match EXACTLY — including whitespace and indentation
|
||||
- Never use placeholder text like "...existing code..." in oldString
|
||||
- After editing, consider reading the file to verify the change
|
||||
- If a string appears multiple times, include more context to make it unique`,
|
||||
|
||||
db_query: `## Database Query Guidelines
|
||||
- ONLY SELECT/SHOW/DESCRIBE — never mutate production data without Commander approval
|
||||
- Always use parameterized queries when possible
|
||||
- Limit result sets — add LIMIT clause for potentially large tables
|
||||
- Never expose raw credentials in query output`,
|
||||
|
||||
web_fetch: `## Web Fetch Guidelines
|
||||
- Never fetch internal/private IPs (SSRF protection enforced by tool)
|
||||
- Validate URLs before fetching — only fetch from domains relevant to the task
|
||||
- Cap response processing at reasonable size
|
||||
- Be cautious with user-provided URLs`,
|
||||
|
||||
mcp_call: `## MCP Bridge Guidelines
|
||||
- Always use mcp_list first to discover available tools before calling
|
||||
- Check tool descriptions for required arguments
|
||||
- MCP tools may have side effects — understand what each does before calling
|
||||
- Some MCP tools may take longer than 25s — be prepared for timeouts`,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GIT SAFETY STEERING
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const GIT_SAFETY = `## Git Safety Rules
|
||||
1. NEVER use \`--force\` or \`--force-with-lease\` on push without asking
|
||||
2. NEVER use \`--no-verify\` to bypass pre-commit hooks
|
||||
3. NEVER amend commits that have been pushed without asking
|
||||
4. NEVER run \`git reset --hard\` without confirming with the Commander
|
||||
5. NEVER delete branches that might have unmerged work
|
||||
6. Always check \`git status\` before committing to know what you're committing
|
||||
7. Use descriptive commit messages (imperative mood, explain WHY not just WHAT)
|
||||
8. When resolving merge conflicts, read BOTH sides before choosing
|
||||
9. For rebases, always do \`git stash\` first if there are uncommitted changes
|
||||
10. Never force-checkout when there are unstaged changes`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SECURITY STEERING
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const SECURITY_RULES = `## Security Rules (Always Active)
|
||||
- NEVER hardcode credentials, API keys, or passwords in any file
|
||||
- NEVER expose secrets in tool output, logs, or responses
|
||||
- NEVER disable HTTPS, certificate verification, or security headers
|
||||
- NEVER create files with world-readable permissions containing secrets
|
||||
- ALWAYS validate user input at system boundaries
|
||||
- ALWAYS use parameterized queries, never string interpolation for SQL
|
||||
- ALWAYS sanitize output to prevent XSS in any web-facing code
|
||||
- ALWAYS check for path traversal in file operations
|
||||
- Credentials: pull from vault, env vars, or tmpfs — never from source code
|
||||
- If you discover a credential in code, flag it immediately and remove it`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// OUTPUT QUALITY STEERING
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const OUTPUT_QUALITY = `## Output Quality
|
||||
- Lead with the answer, not the reasoning process
|
||||
- Be concise — most responses should be 1-3 sentences plus any code
|
||||
- Use absolute paths when referencing files
|
||||
- Use Markdown formatting for structured output
|
||||
- Don't narrate each step — show through actions
|
||||
- Don't add features, refactor, or "improve" beyond what was asked
|
||||
- Don't add docstrings/comments to code you didn't change
|
||||
- Don't add error handling for scenarios that can't happen
|
||||
- Don't create abstractions for one-time operations`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SESSION CONTINUITY STEERING
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const SESSION_CONTINUITY = `## Session Continuity
|
||||
- When you discover important facts, store them in memory immediately
|
||||
- If the session has been compacted, read the transcript if you need exact details
|
||||
- Track what files you've read and modified — this helps with compaction
|
||||
- When resuming after compaction, don't ask the user to repeat context
|
||||
- If context feels incomplete, check memories and transcripts before asking the user`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// STEERING ASSEMBLY
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get the complete steering prompt for a tool.
|
||||
* Returns the tool-specific steering plus universal safety rules.
|
||||
* @param {string} toolName
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getToolSteering(toolName) {
|
||||
const specific = TOOL_STEERING[toolName];
|
||||
if (!specific) return '';
|
||||
return specific;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all steering sections for the system prompt.
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.includeGitSafety - Include git safety rules
|
||||
* @param {boolean} opts.includeSecurity - Include security rules
|
||||
* @param {boolean} opts.includeOutputQuality - Include output quality rules
|
||||
* @param {boolean} opts.includeSessionContinuity - Include session continuity rules
|
||||
* @returns {string[]} Array of steering sections
|
||||
*/
|
||||
export function buildSteeringSections(opts = {}) {
|
||||
const sections = [];
|
||||
|
||||
// Always include security
|
||||
sections.push(SECURITY_RULES);
|
||||
|
||||
// Git safety (default: on)
|
||||
if (opts.includeGitSafety !== false) {
|
||||
sections.push(GIT_SAFETY);
|
||||
}
|
||||
|
||||
// Output quality (default: on)
|
||||
if (opts.includeOutputQuality !== false) {
|
||||
sections.push(OUTPUT_QUALITY);
|
||||
}
|
||||
|
||||
// Session continuity (default: on)
|
||||
if (opts.includeSessionContinuity !== false) {
|
||||
sections.push(SESSION_CONTINUITY);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject steering into tool descriptions.
|
||||
* Appends tool-specific guidelines to each tool's description.
|
||||
* @param {Array} tools - Tool definitions
|
||||
* @returns {Array} Tools with steering-enhanced descriptions
|
||||
*/
|
||||
export function injectToolSteering(tools) {
|
||||
return tools.map(tool => {
|
||||
const steering = getToolSteering(tool.name);
|
||||
if (!steering) return tool;
|
||||
|
||||
return {
|
||||
...tool,
|
||||
description: `${tool.description}\n\n${steering}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
254
src/services/tokenEstimation.js
Normal file
254
src/services/tokenEstimation.js
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* ALFRED AGENT — Token Estimation Engine
|
||||
*
|
||||
* Multi-strategy token counting:
|
||||
* - Rough estimation (chars/bytesPerToken) — instant, no API call
|
||||
* - File-type aware estimation — adjusts for dense formats like JSON
|
||||
* - API-based counting — accurate via Anthropic countTokens endpoint
|
||||
* - Message-level estimation — counts across message arrays
|
||||
*
|
||||
* Built by Commander Danny William Perez and Alfred.
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
// Default bytes per token for general text
|
||||
const DEFAULT_BYTES_PER_TOKEN = 4;
|
||||
|
||||
// File-type specific ratios (dense formats use fewer bytes per token)
|
||||
const FILE_TYPE_RATIOS = {
|
||||
json: 2, jsonl: 2, jsonc: 2,
|
||||
xml: 2.5, html: 2.5, svg: 2.5,
|
||||
yaml: 3, yml: 3,
|
||||
csv: 3, tsv: 3,
|
||||
md: 3.5, txt: 4,
|
||||
js: 3.5, ts: 3.5, jsx: 3.5, tsx: 3.5,
|
||||
py: 3.5, rb: 3.5, php: 3.5,
|
||||
css: 3, scss: 3, less: 3,
|
||||
sh: 3.5, bash: 3.5,
|
||||
sql: 3,
|
||||
c: 3.5, cpp: 3.5, h: 3.5,
|
||||
java: 3.5, go: 3.5, rs: 3.5,
|
||||
};
|
||||
|
||||
// Context window sizes per model family
|
||||
const CONTEXT_WINDOWS = {
|
||||
'claude-sonnet-4-6': 200_000,
|
||||
'claude-sonnet-4-20250514': 200_000,
|
||||
'claude-opus-4-0': 200_000,
|
||||
'claude-3-5-sonnet': 200_000,
|
||||
'claude-3-5-haiku': 200_000,
|
||||
'claude-3-haiku': 200_000,
|
||||
'claude-3-opus': 200_000,
|
||||
'gpt-4o': 128_000,
|
||||
'gpt-4o-mini': 128_000,
|
||||
'llama-3.3-70b-versatile': 128_000,
|
||||
default: 200_000,
|
||||
};
|
||||
|
||||
// Thresholds for auto-compact decisions
|
||||
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
|
||||
export const WARNING_THRESHOLD_BUFFER = 20_000;
|
||||
export const MAX_OUTPUT_SUMMARY_TOKENS = 20_000;
|
||||
export const POST_COMPACT_BUDGET_TOKENS = 50_000;
|
||||
export const MAX_FILES_TO_RESTORE = 5;
|
||||
export const MAX_TOKENS_PER_FILE = 5_000;
|
||||
|
||||
/**
|
||||
* Rough token estimate — fastest method, no API call.
|
||||
* @param {string} content
|
||||
* @param {number} bytesPerToken
|
||||
* @returns {number}
|
||||
*/
|
||||
export function roughEstimate(content, bytesPerToken = DEFAULT_BYTES_PER_TOKEN) {
|
||||
if (!content) return 0;
|
||||
return Math.ceil(content.length / bytesPerToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* File-type aware estimation — adjusts for dense formats.
|
||||
* @param {string} content
|
||||
* @param {string} fileExtension - e.g. 'json', 'js', 'py'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateForFileType(content, fileExtension) {
|
||||
if (!content) return 0;
|
||||
const ext = (fileExtension || '').toLowerCase().replace(/^\./, '');
|
||||
const ratio = FILE_TYPE_RATIOS[ext] || DEFAULT_BYTES_PER_TOKEN;
|
||||
return Math.ceil(content.length / ratio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for a single message (handles both string and content blocks).
|
||||
* @param {Object} message - { role, content }
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateMessageTokens(message) {
|
||||
if (!message) return 0;
|
||||
|
||||
// Fixed overhead per message (role, formatting)
|
||||
const overhead = 4;
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
return overhead + roughEstimate(message.content);
|
||||
}
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
let tokens = overhead;
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'text') {
|
||||
tokens += roughEstimate(block.text || '');
|
||||
} else if (block.type === 'tool_use') {
|
||||
tokens += roughEstimate(block.name || '') + roughEstimate(JSON.stringify(block.input || {}));
|
||||
} else if (block.type === 'tool_result') {
|
||||
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content || '');
|
||||
tokens += roughEstimate(content);
|
||||
} else if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
||||
tokens += roughEstimate(block.thinking || '');
|
||||
} else {
|
||||
tokens += roughEstimate(JSON.stringify(block));
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
return overhead + roughEstimate(JSON.stringify(message.content || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total tokens for an array of messages.
|
||||
* @param {Array} messages
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateConversationTokens(messages) {
|
||||
if (!messages || messages.length === 0) return 0;
|
||||
return messages.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for the system prompt sections.
|
||||
* @param {Array|string} systemPrompt
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateSystemPromptTokens(systemPrompt) {
|
||||
if (!systemPrompt) return 0;
|
||||
const text = Array.isArray(systemPrompt) ? systemPrompt.join('\n\n') : systemPrompt;
|
||||
return roughEstimate(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for tool definitions.
|
||||
* @param {Array} tools
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateToolTokens(tools) {
|
||||
if (!tools || tools.length === 0) return 0;
|
||||
// Each tool def: name + description + schema
|
||||
return tools.reduce((sum, tool) => {
|
||||
return sum + roughEstimate(tool.name || '') +
|
||||
roughEstimate(tool.description || '') +
|
||||
roughEstimate(JSON.stringify(tool.inputSchema || {}));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context window size for a model.
|
||||
* @param {string} model
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getContextWindow(model) {
|
||||
if (!model) return CONTEXT_WINDOWS.default;
|
||||
// Try exact match
|
||||
if (CONTEXT_WINDOWS[model]) return CONTEXT_WINDOWS[model];
|
||||
// Try prefix match
|
||||
for (const [key, value] of Object.entries(CONTEXT_WINDOWS)) {
|
||||
if (key !== 'default' && model.startsWith(key)) return value;
|
||||
}
|
||||
return CONTEXT_WINDOWS.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective context window (minus output reservation).
|
||||
* @param {string} model
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getEffectiveContextWindow(model) {
|
||||
return getContextWindow(model) - MAX_OUTPUT_SUMMARY_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-compact threshold for a model.
|
||||
* @param {string} model
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getAutoCompactThreshold(model) {
|
||||
return getEffectiveContextWindow(model) - AUTOCOMPACT_BUFFER_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token warning state for current usage.
|
||||
* @param {number} tokenUsage
|
||||
* @param {string} model
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function calculateTokenWarnings(tokenUsage, model) {
|
||||
const effectiveWindow = getEffectiveContextWindow(model);
|
||||
const autoCompactThreshold = getAutoCompactThreshold(model);
|
||||
const percentUsed = Math.round((tokenUsage / effectiveWindow) * 100);
|
||||
const percentLeft = Math.max(0, 100 - percentUsed);
|
||||
|
||||
return {
|
||||
tokenUsage,
|
||||
effectiveWindow,
|
||||
autoCompactThreshold,
|
||||
percentUsed,
|
||||
percentLeft,
|
||||
needsAutoCompact: tokenUsage >= autoCompactThreshold,
|
||||
isWarning: tokenUsage >= (effectiveWindow - WARNING_THRESHOLD_BUFFER),
|
||||
isBlocking: tokenUsage >= (effectiveWindow - 3_000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API-based token counting via Anthropic countTokens endpoint.
|
||||
* Falls back to rough estimate on failure.
|
||||
* @param {Object} client - Anthropic client instance
|
||||
* @param {string} model
|
||||
* @param {Array} messages - API-format messages
|
||||
* @param {Array} tools - API-format tool defs (optional)
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function countTokensWithAPI(client, model, messages, tools = []) {
|
||||
try {
|
||||
const toolDefs = tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.inputSchema || t.input_schema,
|
||||
}));
|
||||
|
||||
const response = await client.messages.countTokens({
|
||||
model,
|
||||
messages: messages.length > 0 ? messages : [{ role: 'user', content: 'x' }],
|
||||
...(toolDefs.length > 0 && { tools: toolDefs }),
|
||||
});
|
||||
|
||||
if (typeof response.input_tokens === 'number') {
|
||||
return response.input_tokens;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined token usage estimate for the full API call.
|
||||
* System prompt + messages + tool defs.
|
||||
* @param {Object} params
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateFullCallTokens({ systemPrompt, messages, tools }) {
|
||||
return estimateSystemPromptTokens(systemPrompt) +
|
||||
estimateConversationTokens(messages) +
|
||||
estimateToolTokens(tools);
|
||||
}
|
||||
216
src/tools.js
216
src/tools.js
@ -450,7 +450,7 @@ registerTool({
|
||||
|
||||
registerTool({
|
||||
name: 'mcp_call',
|
||||
description: 'Call any tool from the GoCodeMe MCP server (856+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.',
|
||||
description: 'Call any tool from the GoCodeMe MCP server (875+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -461,18 +461,16 @@ registerTool({
|
||||
},
|
||||
async execute({ tool, args }) {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
id: Date.now(),
|
||||
params: { name: tool, arguments: args || {} },
|
||||
});
|
||||
const fs = await import('fs');
|
||||
const jwtPath = '/run/user/1004/keys/mcp-service.jwt';
|
||||
const token = fs.readFileSync(jwtPath, 'utf8').trim();
|
||||
const payload = JSON.stringify({ name: tool, arguments: args || {} });
|
||||
const result = execSync(
|
||||
`curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`,
|
||||
`curl -s --max-time 25 -X POST http://127.0.0.1:3006/api/tool -H 'Content-Type: application/json' -H 'Authorization: Bearer ${token}' -d '${payload.replace(/'/g, "'\\''")}'`,
|
||||
{ encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
if (parsed.error) return { error: `MCP error: ${parsed.error.message || JSON.stringify(parsed.error)}` };
|
||||
if (parsed.error) return { error: `MCP error: ${parsed.error}` };
|
||||
// Extract content from MCP result format
|
||||
const content = parsed.result?.content;
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
@ -498,37 +496,38 @@ registerTool({
|
||||
},
|
||||
async execute({ category, search }) {
|
||||
try {
|
||||
const result = execSync(
|
||||
`curl -s http://127.0.0.1:3006/mcp/docs/summary`,
|
||||
{ encoding: 'utf8', timeout: 10000 }
|
||||
);
|
||||
const data = JSON.parse(result);
|
||||
let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`;
|
||||
|
||||
if (category || search) {
|
||||
// Get full tool list for filtering
|
||||
const listResult = execSync(
|
||||
`curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 })}'`,
|
||||
{ encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }
|
||||
if (search) {
|
||||
// Use the search endpoint for keyword queries
|
||||
const searchResult = execSync(
|
||||
`curl -s --max-time 10 'http://127.0.0.1:3006/mcp/docs/search?q=${encodeURIComponent(search)}'`,
|
||||
{ encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024 }
|
||||
);
|
||||
const listData = JSON.parse(listResult);
|
||||
let tools = listData.result?.tools || [];
|
||||
|
||||
if (category) {
|
||||
tools = tools.filter(t => (t.category || '').toLowerCase().includes(category.toLowerCase()));
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
tools = tools.filter(t =>
|
||||
(t.name || '').toLowerCase().includes(q) ||
|
||||
(t.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
summary += `Filtered: ${tools.length} tools\n\n`;
|
||||
const searchData = JSON.parse(searchResult);
|
||||
const tools = searchData.results || searchData.tools || [];
|
||||
let summary = `Search "${search}": ${tools.length} results\n\n`;
|
||||
for (const t of tools.slice(0, 50)) {
|
||||
summary += `• ${t.name}: ${(t.description || '').slice(0, 120)}\n`;
|
||||
}
|
||||
if (tools.length > 50) summary += `\n... and ${tools.length - 50} more`;
|
||||
return { result: summary };
|
||||
}
|
||||
|
||||
const result = execSync(
|
||||
`curl -s --max-time 10 http://127.0.0.1:3006/mcp/docs/summary`,
|
||||
{ encoding: 'utf8', timeout: 15000 }
|
||||
);
|
||||
const data = JSON.parse(result);
|
||||
let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`;
|
||||
|
||||
if (category) {
|
||||
const filtered = (data.categories || []).filter(c =>
|
||||
c.key.toLowerCase().includes(category.toLowerCase()) ||
|
||||
c.label.toLowerCase().includes(category.toLowerCase())
|
||||
);
|
||||
for (const cat of filtered) {
|
||||
summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`;
|
||||
}
|
||||
if (filtered.length === 0) summary += 'No matching category found.\n';
|
||||
} else {
|
||||
for (const cat of (data.categories || [])) {
|
||||
summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`;
|
||||
@ -540,3 +539,150 @@ registerTool({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// BRAIN TOOLS — Decay Memory (Omahon pattern)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { createMemoryStore } from './services/decayMemory.js';
|
||||
|
||||
let _memStore = null;
|
||||
function getMemStore() {
|
||||
if (!_memStore) _memStore = createMemoryStore();
|
||||
return _memStore;
|
||||
}
|
||||
|
||||
registerTool({
|
||||
name: 'memory_store',
|
||||
description: 'Store a fact in persistent memory with decay-aware confidence. Facts are automatically deduplicated — storing the same content reinforces it instead of duplicating. Use sections to categorize: architecture, conventions, behavior, decisions, constraints, security, api, general.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mind: { type: 'string', description: 'Memory namespace (e.g. project name). Default: "default"' },
|
||||
section: { type: 'string', description: 'Category: architecture, conventions, behavior, decisions, constraints, security, api, general' },
|
||||
content: { type: 'string', description: 'The fact to remember' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags for filtering' },
|
||||
},
|
||||
required: ['section', 'content'],
|
||||
},
|
||||
async execute({ mind = 'default', section, content, tags }) {
|
||||
const store = getMemStore();
|
||||
store.ensureMind(mind);
|
||||
const result = store.store(mind, section, content, { tags });
|
||||
return { success: true, ...result };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_recall',
|
||||
description: 'Recall facts from memory. Returns facts sorted by confidence (most confident first). Low-confidence facts have decayed and may be stale.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mind: { type: 'string', description: 'Memory namespace. Default: "default"' },
|
||||
section: { type: 'string', description: 'Filter by section (optional)' },
|
||||
minConfidence: { type: 'number', description: 'Minimum confidence threshold (0-1). Default: 0.1' },
|
||||
limit: { type: 'number', description: 'Max facts to return. Default: 20' },
|
||||
},
|
||||
},
|
||||
async execute({ mind = 'default', section, minConfidence = 0.1, limit = 20 }) {
|
||||
const store = getMemStore();
|
||||
const facts = store.recall(mind, { section, minConfidence, limit });
|
||||
return { facts, count: facts.length };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_search',
|
||||
description: 'Search memory by keyword. Returns matching facts across all sections.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
mind: { type: 'string', description: 'Memory namespace. Default: "default"' },
|
||||
limit: { type: 'number', description: 'Max results. Default: 10' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
async execute({ query, mind = 'default', limit = 10 }) {
|
||||
const store = getMemStore();
|
||||
const results = store.search(query, { mind, limit });
|
||||
return { results, count: results.length };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_archive',
|
||||
description: 'Archive a fact (soft delete). The fact remains in the DB but is no longer recalled.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
factId: { type: 'string', description: 'The fact ID to archive' },
|
||||
},
|
||||
required: ['factId'],
|
||||
},
|
||||
async execute({ factId }) {
|
||||
const store = getMemStore();
|
||||
store.archive(factId);
|
||||
return { success: true, archived: factId };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_supersede',
|
||||
description: 'Replace an old fact with a new one. The old fact is marked superseded and the new one links back to it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
oldFactId: { type: 'string', description: 'The fact ID being superseded' },
|
||||
mind: { type: 'string', description: 'Memory namespace' },
|
||||
section: { type: 'string', description: 'Section for the new fact' },
|
||||
content: { type: 'string', description: 'The updated fact content' },
|
||||
},
|
||||
required: ['oldFactId', 'section', 'content'],
|
||||
},
|
||||
async execute({ oldFactId, mind = 'default', section, content }) {
|
||||
const store = getMemStore();
|
||||
const result = store.supersede(oldFactId, mind, section, content);
|
||||
return { success: true, ...result };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_connect',
|
||||
description: 'Create a relation between two facts. Relations: depends_on, contradicts, elaborates, implements, supersedes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sourceId: { type: 'string', description: 'Source fact ID' },
|
||||
targetId: { type: 'string', description: 'Target fact ID' },
|
||||
relation: { type: 'string', description: 'Relation type: depends_on, contradicts, elaborates, implements, supersedes' },
|
||||
description: { type: 'string', description: 'Optional description of the relation' },
|
||||
},
|
||||
required: ['sourceId', 'targetId', 'relation'],
|
||||
},
|
||||
async execute({ sourceId, targetId, relation, description }) {
|
||||
const store = getMemStore();
|
||||
const edgeId = store.connect(sourceId, targetId, relation, description);
|
||||
return { success: true, edgeId };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: 'memory_episode',
|
||||
description: 'Record an episode — a narrative summary of a work session or event. Episodes tie together related facts into a story.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mind: { type: 'string', description: 'Memory namespace. Default: "default"' },
|
||||
title: { type: 'string', description: 'Episode title' },
|
||||
narrative: { type: 'string', description: 'Narrative summary of what happened' },
|
||||
},
|
||||
required: ['title', 'narrative'],
|
||||
},
|
||||
async execute({ mind = 'default', title, narrative }) {
|
||||
const store = getMemStore();
|
||||
const id = store.recordEpisode(mind, title, narrative);
|
||||
return { success: true, episodeId: id };
|
||||
},
|
||||
});
|
||||
|
||||
271
upgrade.sh
Executable file
271
upgrade.sh
Executable file
@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ALFRED AGENT — Upgrade Safeguard Script
|
||||
#
|
||||
# Handles:
|
||||
# 1. Pre-upgrade backup of all source, config, data
|
||||
# 2. Post-upgrade branding re-application (code-server)
|
||||
# 3. Safe PM2 restart with rollback on failure
|
||||
# 4. Health check verification
|
||||
#
|
||||
# Usage:
|
||||
# ./upgrade.sh backup # Create timestamped backup
|
||||
# ./upgrade.sh brand # Re-apply code-server branding patches
|
||||
# ./upgrade.sh restart # Safe PM2 restart with health check
|
||||
# ./upgrade.sh full # All three in sequence
|
||||
# ./upgrade.sh rollback # Restore from latest backup
|
||||
# ./upgrade.sh status # Show current agent status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_DIR="$HOME/alfred-agent"
|
||||
BACKUP_ROOT="$HOME/backups/alfred-agent"
|
||||
CS_DIR="$HOME/.local/share/code-server/lib/vscode"
|
||||
HEALTH_URL="http://127.0.0.1:3102/health"
|
||||
PM2_ID=80
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_DIR="$BACKUP_ROOT/$TS"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${CYAN}[Alfred]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN} ✓${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW} ⚠${NC} $1"; }
|
||||
fail() { echo -e "${RED} ✗${NC} $1"; }
|
||||
|
||||
# ── Backup ────────────────────────────────────────────────────────────
|
||||
do_backup() {
|
||||
log "Creating backup at $BACKUP_DIR"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Agent source
|
||||
cp -r "$AGENT_DIR/src" "$BACKUP_DIR/src"
|
||||
ok "Source code backed up ($(find "$AGENT_DIR/src" -name '*.js' | wc -l) files)"
|
||||
|
||||
# Config files
|
||||
cp "$AGENT_DIR/package.json" "$BACKUP_DIR/"
|
||||
cp "$AGENT_DIR/ecosystem.config.cjs" "$BACKUP_DIR/"
|
||||
[ -f "$AGENT_DIR/.gitignore" ] && cp "$AGENT_DIR/.gitignore" "$BACKUP_DIR/"
|
||||
ok "Config files backed up"
|
||||
|
||||
# Session data (important — don't lose conversation history)
|
||||
if [ -d "$AGENT_DIR/data" ]; then
|
||||
cp -r "$AGENT_DIR/data" "$BACKUP_DIR/data"
|
||||
local session_count=$(ls "$AGENT_DIR/data/sessions/" 2>/dev/null | wc -l)
|
||||
ok "Data directory backed up ($session_count sessions)"
|
||||
fi
|
||||
|
||||
# Record version info
|
||||
cat > "$BACKUP_DIR/MANIFEST.txt" <<EOF
|
||||
Alfred Agent Backup — $TS
|
||||
Version: $(node -e "console.log(JSON.parse(require('fs').readFileSync('$AGENT_DIR/package.json','utf8')).version)" 2>/dev/null || echo "unknown")
|
||||
Node: $(node -v)
|
||||
Source files: $(find "$AGENT_DIR/src" -name '*.js' | wc -l)
|
||||
Total lines: $(find "$AGENT_DIR/src" -name '*.js' -exec cat {} + | wc -l)
|
||||
Services: $(ls "$AGENT_DIR/src/services/" 2>/dev/null | wc -l)
|
||||
Sessions: $(ls "$AGENT_DIR/data/sessions/" 2>/dev/null | wc -l)
|
||||
EOF
|
||||
ok "Manifest written"
|
||||
|
||||
# Prune old backups (keep last 10)
|
||||
local count=$(ls -d "$BACKUP_ROOT"/20* 2>/dev/null | wc -l)
|
||||
if [ "$count" -gt 10 ]; then
|
||||
ls -d "$BACKUP_ROOT"/20* | head -n $(( count - 10 )) | xargs rm -rf
|
||||
warn "Pruned old backups (keeping last 10)"
|
||||
fi
|
||||
|
||||
log "Backup complete: $BACKUP_DIR"
|
||||
}
|
||||
|
||||
# ── Branding ─────────────────────────────────────────────────────────
|
||||
do_brand() {
|
||||
log "Re-applying Alfred IDE branding patches to code-server..."
|
||||
|
||||
local WB="$CS_DIR/out/vs/workbench/workbench.web.main.js"
|
||||
local NLS_JS="$CS_DIR/out/nls.messages.js"
|
||||
local NLS_JSON="$CS_DIR/out/nls.messages.json"
|
||||
|
||||
if [ ! -f "$WB" ]; then
|
||||
fail "workbench.js not found at $WB — is code-server installed?"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Backup before patching
|
||||
[ ! -f "$WB.bak" ] && cp "$WB" "$WB.bak"
|
||||
[ -f "$NLS_JS" ] && [ ! -f "$NLS_JS.bak" ] && cp "$NLS_JS" "$NLS_JS.bak"
|
||||
[ -f "$NLS_JSON" ] && [ ! -f "$NLS_JSON.bak" ] && cp "$NLS_JSON" "$NLS_JSON.bak"
|
||||
|
||||
# Workbench.js patches (About dialog, menus, notifications)
|
||||
sed -i 's/nameShort:"code-server"/nameShort:"Alfred IDE"/g' "$WB"
|
||||
sed -i 's/nameLong:"code-server"/nameLong:"Alfred IDE"/g' "$WB"
|
||||
# Secure context warning + logout menu
|
||||
sed -i 's/d(3228,null,"code-server")/d(3228,null,"Alfred IDE")/g' "$WB"
|
||||
sed -i 's/d(3230,null,"code-server")/d(3230,null,"Alfred IDE")/g' "$WB"
|
||||
# Update notification
|
||||
sed -i 's/\[code-server v/[Alfred IDE v/g' "$WB"
|
||||
|
||||
local wb_count=$(grep -c "Alfred IDE" "$WB" 2>/dev/null || echo 0)
|
||||
ok "workbench.js: $wb_count 'Alfred IDE' references applied"
|
||||
|
||||
# NLS patches (Welcome page, walkthrough headers)
|
||||
if [ -f "$NLS_JS" ]; then
|
||||
sed -i 's/Get Started with VS Code for the Web/Get Started with Alfred IDE/g' "$NLS_JS"
|
||||
sed -i 's/Get Started with VS Code/Get Started with Alfred IDE/g' "$NLS_JS"
|
||||
ok "nls.messages.js patched"
|
||||
fi
|
||||
if [ -f "$NLS_JSON" ]; then
|
||||
sed -i 's/Get Started with VS Code for the Web/Get Started with Alfred IDE/g' "$NLS_JSON"
|
||||
sed -i 's/Get Started with VS Code/Get Started with Alfred IDE/g' "$NLS_JSON"
|
||||
ok "nls.messages.json patched"
|
||||
fi
|
||||
|
||||
log "Branding patches applied. Restart code-server (PM2 35) to take effect."
|
||||
}
|
||||
|
||||
# ── PM2 Restart with Health Check ────────────────────────────────────
|
||||
do_restart() {
|
||||
log "Restarting Alfred Agent (PM2 $PM2_ID)..."
|
||||
|
||||
# Syntax check first
|
||||
if ! node -e "import('$AGENT_DIR/src/index.js').then(() => setTimeout(() => process.exit(0), 2000)).catch(e => { console.error(e.message); process.exit(1); })" 2>/dev/null; then
|
||||
fail "Import check failed — NOT restarting. Fix errors first."
|
||||
return 1
|
||||
fi
|
||||
ok "Import check passed"
|
||||
|
||||
pm2 restart $PM2_ID --update-env 2>/dev/null
|
||||
ok "PM2 restart issued"
|
||||
|
||||
# Wait and health check
|
||||
sleep 3
|
||||
local health
|
||||
health=$(curl -s --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$health" | grep -q '"status":"online"'; then
|
||||
local version=$(echo "$health" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','?'))" 2>/dev/null || echo "?")
|
||||
ok "Health check passed — v$version online"
|
||||
else
|
||||
fail "Health check FAILED — agent may not be running"
|
||||
warn "Check logs: pm2 logs $PM2_ID --lines 20"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Rollback ─────────────────────────────────────────────────────────
|
||||
do_rollback() {
|
||||
local latest=$(ls -d "$BACKUP_ROOT"/20* 2>/dev/null | tail -1)
|
||||
if [ -z "$latest" ]; then
|
||||
fail "No backups found in $BACKUP_ROOT"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Rolling back from $latest"
|
||||
|
||||
# Restore source
|
||||
rm -rf "$AGENT_DIR/src"
|
||||
cp -r "$latest/src" "$AGENT_DIR/src"
|
||||
ok "Source restored"
|
||||
|
||||
# Restore config
|
||||
cp "$latest/package.json" "$AGENT_DIR/"
|
||||
cp "$latest/ecosystem.config.cjs" "$AGENT_DIR/"
|
||||
ok "Config restored"
|
||||
|
||||
do_restart
|
||||
log "Rollback complete"
|
||||
}
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────
|
||||
do_status() {
|
||||
echo ""
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ ALFRED AGENT — System Status ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Health
|
||||
local health
|
||||
health=$(curl -s --max-time 5 "$HEALTH_URL" 2>/dev/null || echo '{"status":"offline"}')
|
||||
local status=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','offline'))" 2>/dev/null || echo "offline")
|
||||
local version=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||
local sessions=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('activeSessions',0))" 2>/dev/null || echo "0")
|
||||
local features=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(', '.join(d.get('features',[])))" 2>/dev/null || echo "?")
|
||||
|
||||
if [ "$status" = "online" ]; then
|
||||
echo -e " Status: ${GREEN}● ONLINE${NC}"
|
||||
else
|
||||
echo -e " Status: ${RED}● OFFLINE${NC}"
|
||||
fi
|
||||
echo " Version: $version"
|
||||
echo " Sessions: $sessions active"
|
||||
echo " Features: $features"
|
||||
echo ""
|
||||
|
||||
# Source stats
|
||||
local src_files=$(find "$AGENT_DIR/src" -name '*.js' | wc -l)
|
||||
local src_lines=$(find "$AGENT_DIR/src" -name '*.js' -exec cat {} + | wc -l)
|
||||
local svc_count=$(ls "$AGENT_DIR/src/services/" 2>/dev/null | wc -l)
|
||||
echo " Source: $src_files files, $src_lines lines"
|
||||
echo " Services: $svc_count modules"
|
||||
echo ""
|
||||
|
||||
# GoForge Git
|
||||
if [ -d "$AGENT_DIR/.git" ]; then
|
||||
local branch=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
|
||||
local dirty=$(cd "$AGENT_DIR" && git status --porcelain 2>/dev/null | wc -l)
|
||||
local last_commit=$(cd "$AGENT_DIR" && git log --oneline -1 2>/dev/null || echo "no commits")
|
||||
echo " GoForge: branch=$branch, $dirty dirty files"
|
||||
echo " Last: $last_commit"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Backups
|
||||
local backup_count=$(ls -d "$BACKUP_ROOT"/20* 2>/dev/null | wc -l)
|
||||
local latest_backup=$(ls -d "$BACKUP_ROOT"/20* 2>/dev/null | tail -1 | xargs basename 2>/dev/null || echo "none")
|
||||
echo " Backups: $backup_count saved (latest: $latest_backup)"
|
||||
echo ""
|
||||
|
||||
# PM2
|
||||
echo " PM2 ID: $PM2_ID"
|
||||
pm2 show $PM2_ID 2>/dev/null | grep -E "status|uptime|restart" | head -3 | sed 's/^/ /'
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Full Upgrade ─────────────────────────────────────────────────────
|
||||
do_full() {
|
||||
log "Running full upgrade sequence..."
|
||||
echo ""
|
||||
do_backup
|
||||
echo ""
|
||||
do_brand
|
||||
echo ""
|
||||
do_restart
|
||||
echo ""
|
||||
log "Full upgrade sequence complete."
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
case "${1:-help}" in
|
||||
backup) do_backup ;;
|
||||
brand) do_brand ;;
|
||||
restart) do_restart ;;
|
||||
full) do_full ;;
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
*)
|
||||
echo "Alfred Agent Upgrade Safeguard"
|
||||
echo ""
|
||||
echo "Usage: $0 {backup|brand|restart|full|rollback|status}"
|
||||
echo ""
|
||||
echo " backup Create timestamped backup of source, config, data"
|
||||
echo " brand Re-apply Alfred IDE branding to code-server"
|
||||
echo " restart Safe PM2 restart with health check"
|
||||
echo " full backup + brand + restart (for after code-server upgrades)"
|
||||
echo " rollback Restore from latest backup"
|
||||
echo " status Show agent status, features, GoForge, backups"
|
||||
;;
|
||||
esac
|
||||
Loading…
Reference in New Issue
Block a user