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:
Commander 2026-04-08 19:01:30 -04:00
parent e89ce12316
commit b7785c106b
26 changed files with 6726 additions and 112 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
data/ data/
.env .env
*.key *.key
*.bak

212
consolidate-workspace.sh Executable file
View 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
View File

@ -8,7 +8,8 @@
"name": "alfred-agent", "name": "alfred-agent",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.39.0" "@anthropic-ai/sdk": "^0.39.0",
"better-sqlite3": "^12.8.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
@ -75,6 +76,84 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "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": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "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": ">= 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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -100,6 +185,30 @@
"node": ">= 0.8" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -109,6 +218,15 @@
"node": ">=0.4.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -123,6 +241,15 @@
"node": ">= 0.4" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -177,6 +304,21 @@
"node": ">=6" "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": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@ -212,6 +354,12 @@
"node": ">= 12.20" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -258,6 +406,12 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -318,6 +472,38 @@
"ms": "^2.0.0" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -348,12 +534,57 @@
"node": ">= 0.6" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "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": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT" "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": { "node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3", "version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", "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", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "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"
} }
} }
} }

View File

@ -10,6 +10,7 @@
"test": "node src/test.js" "test": "node src/test.js"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.39.0" "@anthropic-ai/sdk": "^0.39.0",
"better-sqlite3": "^12.8.0"
} }
} }

View File

@ -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. * 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 * Built by Commander Danny William Perez and Alfred.
* loop until done. Simple plumbing, infinite power.
* *
*/ */
import { getTools, executeTool } from './tools.js'; import { getTools, executeTool, registerTool } from './tools.js';
import { buildSystemPrompt } from './prompt.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 { 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 // Max tool execution rounds per user message
const MAX_TOOL_ROUNDS = 25; 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} provider - AI provider (from providers.js)
* @param {Object} opts - Options * @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.profile - Hook profile: 'commander' or 'customer'
* @param {string} opts.clientId - Customer client ID (for sandbox scoping) * @param {string} opts.clientId - Customer client ID (for sandbox scoping)
* @param {string} opts.workspaceRoot - Customer workspace root dir * @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.onText - Callback for text output
* @param {Function} opts.onToolUse - Callback for tool execution events * @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.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 = {}) { export async function createAgent(provider, opts = {}) {
const tools = getTools();
const cwd = opts.cwd || process.cwd(); const cwd = opts.cwd || process.cwd();
// Initialize or resume session // ── Initialize or resume session ──────────────────────────────────
let session; let session;
if (opts.sessionId) { if (opts.sessionId) {
session = loadSession(opts.sessionId); session = loadSession(opts.sessionId);
@ -49,7 +69,14 @@ export function createAgent(provider, opts = {}) {
session = createSession(); 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 // Hook engine — gates all tool execution
const hookEngine = opts.hookEngine || createHookEngine(opts.profile || 'commander', { const hookEngine = opts.hookEngine || createHookEngine(opts.profile || 'commander', {
@ -58,7 +85,33 @@ export function createAgent(provider, opts = {}) {
onHookEvent: opts.onHookEvent, 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 onText = opts.onText || (text => process.stdout.write(text));
const onToolUse = opts.onToolUse || ((name, input) => { const onToolUse = opts.onToolUse || ((name, input) => {
console.error(`\x1b[36m⚡ Tool: ${name}\x1b[0m`); 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`); console.error(`\x1b[32m✓ ${name}: ${preview}\x1b[0m`);
}); });
const onError = opts.onError || (err => console.error(`\x1b[31m✗ Error: ${err}\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. * 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) { async function processMessage(userMessage) {
// Add user message to session // ── Add user message to session ───────────────────────────────
addMessage(session, 'user', userMessage); addMessage(session, 'user', userMessage);
// Check if we need to compact // ── Match skills ──────────────────────────────────────────────
if (session.messages.length > COMPACTION_THRESHOLD) { const skillPrompts = skillEngine.getActiveSkillPrompts(
console.error('\x1b[33m📦 Compacting session...\x1b[0m'); typeof userMessage === 'string' ? userMessage : '',
compactSession(session); );
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 round = 0;
let lastModel = null; 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) { while (round < MAX_TOOL_ROUNDS) {
round++; round++;
@ -95,12 +188,21 @@ export function createAgent(provider, opts = {}) {
// 1. Send messages to the AI provider // 1. Send messages to the AI provider
let response; let response;
try { try {
response = await provider.query({ const queryParams = {
systemPrompt, systemPrompt: effectiveSystemPrompt,
messages: getAPIMessages(session), messages: getAPIMessages(session),
tools, tools: steeredTools,
maxTokens: 8192, 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) { } catch (err) {
onError(`Provider error: ${err.message}`); onError(`Provider error: ${err.message}`);
break; break;
@ -109,6 +211,7 @@ export function createAgent(provider, opts = {}) {
// Track usage // Track usage
if (response.usage) { if (response.usage) {
session.totalTokensUsed += (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0); session.totalTokensUsed += (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0);
lastUsage = response.usage;
} }
lastModel = response.model || lastModel; lastModel = response.model || lastModel;
@ -120,38 +223,51 @@ export function createAgent(provider, opts = {}) {
for (const block of assistantContent) { for (const block of assistantContent) {
if (block.type === 'text') { if (block.type === 'text') {
textParts.push(block.text); textParts.push(block.text);
onText(block.text); if (!opts.stream || !provider.streamQuery) {
onText(block.text);
}
} else if (block.type === 'tool_use') { } else if (block.type === 'tool_use') {
toolUseBlocks.push(block); toolUseBlocks.push(block);
onToolUse(block.name, block.input); if (!opts.stream || !provider.streamQuery) {
onToolUse(block.name, block.input);
}
} }
} }
// Save assistant response to session // Save assistant response to session
addMessage(session, 'assistant', assistantContent); 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) { if (response.stopReason !== 'tool_use' || toolUseBlocks.length === 0) {
break; break;
} }
// 4. Execute all tool calls — WITH HOOK GATES // 4. Execute all tool calls — WITH HOOK GATES + CONTEXT TRACKING
const toolResults = []; const toolResults = [];
for (const toolCall of toolUseBlocks) { 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); const preResult = await hookEngine.runPreToolUse(toolCall.name, toolCall.input);
let result; let result;
if (preResult.action === 'block') { if (preResult.action === 'block') {
// Hook blocked the tool — tell the model why
result = { error: `BLOCKED by policy: ${preResult.reason}` }; result = { error: `BLOCKED by policy: ${preResult.reason}` };
onError(`Hook blocked ${toolCall.name}: ${preResult.reason}`); onError(`Hook blocked ${toolCall.name}: ${preResult.reason}`);
} else { } else {
// Use potentially modified input from hooks
const finalInput = preResult.input || toolCall.input; const finalInput = preResult.input || toolCall.input;
result = await executeTool(toolCall.name, finalInput); result = await executeTool(toolCall.name, finalInput);
// ── PostToolUse Hook ───────────────────────────────── // ── PostToolUse Hook ─────────────────────────────────────
const postResult = await hookEngine.runPostToolUse(toolCall.name, finalInput, result); const postResult = await hookEngine.runPostToolUse(toolCall.name, finalInput, result);
if (postResult.result !== undefined) { if (postResult.result !== undefined) {
result = postResult.result; result = postResult.result;
@ -162,28 +278,60 @@ export function createAgent(provider, opts = {}) {
toolResults.push({ toolResults.push({
type: 'tool_result', type: 'tool_result',
tool_use_id: toolCall.id, 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); addMessage(session, 'user', toolResults);
// Loop continues — the model will process tool results and decide // 6. Check compaction between tool rounds (every 5 rounds)
// whether to call more tools or respond to the user 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) { if (round >= MAX_TOOL_ROUNDS) {
onError(`Hit max tool rounds (${MAX_TOOL_ROUNDS}). Stopping.`); onError(`Hit max tool rounds (${MAX_TOOL_ROUNDS}). Stopping.`);
} }
compactTracking.turnCounter++;
saveSession(session); saveSession(session);
// ── Brain: persist intent state ──────────────────────────────
intent.incrementTurn();
if (lastUsage) {
intent.addTokens((lastUsage.input_tokens || 0) + (lastUsage.output_tokens || 0));
}
intent.save();
return { return {
sessionId: session.id, sessionId: session.id,
turns: session.turnCount, turns: session.turnCount,
tokensUsed: session.totalTokensUsed, tokensUsed: session.totalTokensUsed,
model: lastModel || provider.model, 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, processMessage,
getSession: () => session, getSession: () => session,
getSessionId: () => session.id, 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;
}
}

View File

@ -1,13 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* *
* ALFRED AGENT Interactive CLI * ALFRED AGENT Interactive CLI (v2)
* *
* Usage: * Usage:
* node src/cli.js # New session * node src/cli.js # New session
* node src/cli.js --resume <id> # Resume session * node src/cli.js --resume <id> # Resume session
* node src/cli.js --sessions # List sessions * node src/cli.js --sessions # List sessions
* node src/cli.js -m "message" # Single message mode * 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'; 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] === '--model') flags.model = args[++i];
else if (args[i] === '--provider') flags.provider = args[++i]; else if (args[i] === '--provider') flags.provider = args[++i];
else if (args[i] === '--profile') flags.profile = 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; 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 -s List sessions
alfred-agent --model opus Use specific model alfred-agent --model opus Use specific model
alfred-agent --provider groq Use specific provider 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: Providers:
anthropic (default) Claude (needs ANTHROPIC_API_KEY) anthropic (default) Claude (needs ANTHROPIC_API_KEY)
@ -99,10 +113,11 @@ try {
} }
// ── Create agent ───────────────────────────────────────────────────── // ── Create agent ─────────────────────────────────────────────────────
const agent = createAgent(provider, { const agent = await createAgent(provider, {
sessionId: flags.resume, sessionId: flags.resume,
cwd: process.cwd(), cwd: process.cwd(),
profile: flags.profile || 'commander', profile: flags.profile || 'commander',
stream: !!flags.stream,
onText: (text) => process.stdout.write(text), onText: (text) => process.stdout.write(text),
onToolUse: (name, input) => { onToolUse: (name, input) => {
console.error(`\n\x1b[36m\u26a1 ${name}\x1b[0m ${JSON.stringify(input).slice(0, 120)}`); 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)`); console.error(`\x1b[32m✓ ${name}\x1b[0m (${str.length} bytes)`);
}, },
onError: (err) => console.error(`\x1b[31m✗ ${err}\x1b[0m`), 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 ─────────────────────────────────────────────────────────── // ── Banner ───────────────────────────────────────────────────────────
console.log(` console.log(`
\x1b[36m \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)} Provider: ${provider.name.padEnd(15)} Model: ${provider.model.padEnd(20)}
Session: ${agent.getSessionId().padEnd(46)} Session: ${agent.getSessionId().padEnd(46)}
\x1b[0m \x1b[0m
@ -164,8 +187,44 @@ rl.on('line', async (line) => {
return; return;
} }
if (input === '/compact') { if (input === '/compact') {
agent.compact(); try {
console.log('Session compacted.'); 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(); rl.prompt();
return; return;
} }
@ -181,6 +240,9 @@ rl.on('line', async (line) => {
/quit, /exit Exit /quit, /exit Exit
/session Show current session info /session Show current session info
/sessions List recent sessions /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 /compact Compact session to free context
/help This help /help This help
`); `);
@ -191,7 +253,9 @@ rl.on('line', async (line) => {
try { try {
console.log(); // Blank line before response console.log(); // Blank line before response
const result = await agent.processMessage(input); 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) { } catch (err) {
console.error(`\x1b[31mError: ${err.message}\x1b[0m`); console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
} }

View File

@ -1,6 +1,6 @@
/** /**
* *
* ALFRED AGENT HTTP Server * ALFRED AGENT HTTP Server (v2)
* *
* Exposes the agent harness via HTTP API for integration with: * Exposes the agent harness via HTTP API for integration with:
* - Alfred IDE chat panel * - Alfred IDE chat panel
@ -8,17 +8,29 @@
* - Voice AI pipeline * - Voice AI pipeline
* - Any internal service * - 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. * Binds to 127.0.0.1 only not exposed to internet.
* *
*/ */
import { createServer } from 'http'; import { createServer } from 'http';
import { URL } from 'url'; import { URL } from 'url';
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
import { createAgent } from './agent.js'; import { createAgent } from './agent.js';
import { createAnthropicProvider, createOpenAICompatProvider } from './providers.js'; import { createAnthropicProvider, createOpenAICompatProvider } from './providers.js';
import { listSessions } from './session.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 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 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 // Active agents keyed by session ID
const agents = new Map(); const agents = new Map();
@ -38,19 +50,22 @@ function getOrCreateProvider(providerName = 'anthropic', model) {
return createAnthropicProvider({ 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); if (sessionId && agents.has(sessionId)) return agents.get(sessionId);
const provider = getOrCreateProvider(providerName, model); const provider = getOrCreateProvider(providerName, model);
const textChunks = []; const textChunks = [];
const toolEvents = []; const toolEvents = [];
const agent = createAgent(provider, { const agent = await createAgent(provider, {
sessionId, sessionId,
stream: extraOpts.stream || false,
onText: (text) => textChunks.push(text), onText: (text) => textChunks.push(text),
onToolUse: (name, input) => toolEvents.push({ type: 'tool_use', name, input }), onToolUse: (name, input) => toolEvents.push({ type: 'tool_use', name, input }),
onToolResult: (name, result) => toolEvents.push({ type: 'tool_result', name, result }), onToolResult: (name, result) => toolEvents.push({ type: 'tool_result', name, result }),
onError: (err) => toolEvents.push({ type: 'error', message: err }), 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 }); agents.set(agent.getSessionId(), { agent, textChunks, toolEvents });
@ -60,7 +75,7 @@ function getOrCreateAgent(sessionId, providerName, model) {
function sendJSON(res, status, data) { function sendJSON(res, status, data) {
res.writeHead(status, { res.writeHead(status, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Alfred-Agent': 'v1.0.0', 'X-Alfred-Agent': 'v2.0.0',
}); });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
@ -81,9 +96,10 @@ const server = createServer(async (req, res) => {
return sendJSON(res, 200, { return sendJSON(res, 200, {
status: 'online', status: 'online',
agent: 'Alfred Agent Harness', agent: 'Alfred Agent Harness',
version: '1.0.0', version: '2.0.0',
activeSessions: agents.size, activeSessions: agents.size,
uptime: process.uptime(), 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' }); 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 // Clear buffers
textChunks.length = 0; 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 ──────────────────────────────────────────────────────── // ── 404 ────────────────────────────────────────────────────────
sendJSON(res, 404, { error: 'Not found' }); sendJSON(res, 404, { error: 'Not found' });
@ -138,13 +297,22 @@ function readBody(req) {
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
console.log(` console.log(`
ALFRED AGENT SERVER v1.0.0 ALFRED AGENT SERVER v2.0.0
Listening on ${HOST}:${PORT} Listening on ${HOST}:${PORT}
Endpoints: Endpoints:
GET /health Health check GET /health Health check + features
GET /sessions List sessions GET /sessions List sessions
POST /chat Send a message 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
`); `);
}); });

View File

@ -15,7 +15,7 @@ const HOME = homedir();
* Build the complete system prompt from layered sections. * Build the complete system prompt from layered sections.
* Sections are composed dynamically based on context. * 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 = [ const sections = [
getIdentitySection(), getIdentitySection(),
getCommanderSection(), getCommanderSection(),
@ -25,7 +25,7 @@ export function buildSystemPrompt({ tools = [], sessionId = null, cwd = null })
getActionsSection(), getActionsSection(),
getToneSection(), getToneSection(),
getEnvironmentSection(cwd), getEnvironmentSection(cwd),
getMemorySection(), await getMemorySection(),
getSessionSection(sessionId), getSessionSection(sessionId),
].filter(Boolean); ].filter(Boolean);
@ -137,22 +137,35 @@ function getEnvironmentSection(cwd) {
- Runtime: Node.js ${process.version}`; - Runtime: Node.js ${process.version}`;
} }
function getMemorySection() { async function getMemorySection() {
const memDir = join(HOME, 'alfred-agent', 'data', 'memories'); const memDir = join(HOME, 'alfred-agent', 'data', 'memories');
if (!existsSync(memDir)) return null;
const files = readdirSync(memDir).filter(f => f.endsWith('.md')); const parts = [];
if (files.length === 0) return null;
// 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');
return `# Persistent Memories
${memories}`; // 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);
}
}
// 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 */ }
if (parts.length === 0) return null;
return `# Persistent Memories\n\n${parts.join('\n\n')}`;
} }
function getSessionSection(sessionId) { function getSessionSection(sessionId) {

View File

@ -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 Anthropic from '@anthropic-ai/sdk';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
@ -18,7 +24,7 @@ function loadKeyFromVault(name) {
return process.env[`${name.toUpperCase()}_API_KEY`] || null; return process.env[`${name.toUpperCase()}_API_KEY`] || null;
} }
/** Anthropic Claude provider */ /** Anthropic Claude provider — with streaming support */
export function createAnthropicProvider(opts = {}) { export function createAnthropicProvider(opts = {}) {
const apiKey = opts.apiKey || loadKeyFromVault('anthropic') || process.env.ANTHROPIC_API_KEY; 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'); 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 { return {
name: 'anthropic', name: 'anthropic',
model, model,
client, // Expose client for token counting
/** Non-streaming query */
async query({ systemPrompt, messages, tools, maxTokens = 8192 }) { async query({ systemPrompt, messages, tools, maxTokens = 8192 }) {
const toolDefs = tools.map(t => ({ const toolDefs = tools.map(t => ({
name: t.name, name: t.name,
@ -52,6 +60,107 @@ export function createAnthropicProvider(opts = {}) {
model: response.model, 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
View 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
View 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 };
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}`,
};
});
}

View 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);
}

View File

@ -450,7 +450,7 @@ registerTool({
registerTool({ registerTool({
name: 'mcp_call', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -461,18 +461,16 @@ registerTool({
}, },
async execute({ tool, args }) { async execute({ tool, args }) {
try { try {
const payload = JSON.stringify({ const fs = await import('fs');
jsonrpc: '2.0', const jwtPath = '/run/user/1004/keys/mcp-service.jwt';
method: 'tools/call', const token = fs.readFileSync(jwtPath, 'utf8').trim();
id: Date.now(), const payload = JSON.stringify({ name: tool, arguments: args || {} });
params: { name: tool, arguments: args || {} },
});
const result = execSync( 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 } { encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }
); );
const parsed = JSON.parse(result); 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 // Extract content from MCP result format
const content = parsed.result?.content; const content = parsed.result?.content;
if (Array.isArray(content) && content.length > 0) { if (Array.isArray(content) && content.length > 0) {
@ -498,37 +496,38 @@ registerTool({
}, },
async execute({ category, search }) { async execute({ category, search }) {
try { try {
const result = execSync( if (search) {
`curl -s http://127.0.0.1:3006/mcp/docs/summary`, // Use the search endpoint for keyword queries
{ encoding: 'utf8', timeout: 10000 } const searchResult = execSync(
); `curl -s --max-time 10 'http://127.0.0.1:3006/mcp/docs/search?q=${encodeURIComponent(search)}'`,
const data = JSON.parse(result); { encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024 }
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 }
); );
const listData = JSON.parse(listResult); const searchData = JSON.parse(searchResult);
let tools = listData.result?.tools || []; const tools = searchData.results || searchData.tools || [];
let summary = `Search "${search}": ${tools.length} results\n\n`;
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`;
for (const t of tools.slice(0, 50)) { for (const t of tools.slice(0, 50)) {
summary += `${t.name}: ${(t.description || '').slice(0, 120)}\n`; summary += `${t.name}: ${(t.description || '').slice(0, 120)}\n`;
} }
if (tools.length > 50) summary += `\n... and ${tools.length - 50} more`; 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 { } else {
for (const cat of (data.categories || [])) { for (const cat of (data.categories || [])) {
summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`; 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
View 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