diff --git a/.gitignore b/.gitignore index 81eefa7..beed50b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ data/ .env *.key +*.bak diff --git a/consolidate-workspace.sh b/consolidate-workspace.sh new file mode 100755 index 0000000..af48f07 --- /dev/null +++ b/consolidate-workspace.sh @@ -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 "═══════════════════════════════════════════════════════════════" diff --git a/package-lock.json b/package-lock.json index 61f57e6..b4f02ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "alfred-agent", "version": "1.0.0", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.8.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -75,6 +76,84 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -88,6 +167,12 @@ "node": ">= 0.4" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -100,6 +185,30 @@ "node": ">= 0.8" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -109,6 +218,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -123,6 +241,15 @@ "node": ">= 0.4" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -177,6 +304,21 @@ "node": ">=6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -212,6 +354,12 @@ "node": ">= 12.20" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -258,6 +406,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -318,6 +472,38 @@ "ms": "^2.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -348,12 +534,57 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -394,18 +625,234 @@ } } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -430,6 +877,12 @@ "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 8ecb9bb..0a9968a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "node src/test.js" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.8.0" } } diff --git a/src/agent.js b/src/agent.js index 46a9b5f..e155298 100644 --- a/src/agent.js +++ b/src/agent.js @@ -1,26 +1,42 @@ /** * ═══════════════════════════════════════════════════════════════════════════ - * ALFRED AGENT HARNESS — Core Agent Loop + * ALFRED AGENT HARNESS — Core Agent Loop (v2) * * The beating heart of Alfred's sovereign agent runtime. - * Built by Commander Danny William Perez and Alfred. + * Now powered by: + * - 4-tier compaction (micro, auto, session-memory, post-compact) + * - Token estimation and context window management + * - Streaming responses + * - Context tracking (files, git, errors, discoveries) + * - Skills engine (auto-invoked on trigger match) + * - Agent forking (sub-agent tool) + * - Task tracking (create, update, list sub-tasks) + * - Steering prompts (tool-specific safety and quality rules) * - * This is the loop. User message in → tools execute → results feed back → - * loop until done. Simple plumbing, infinite power. + * Built by Commander Danny William Perez and Alfred. * ═══════════════════════════════════════════════════════════════════════════ */ -import { getTools, executeTool } from './tools.js'; +import { getTools, executeTool, registerTool } from './tools.js'; import { buildSystemPrompt } from './prompt.js'; -import { createSession, loadSession, addMessage, getAPIMessages, compactSession, saveSession } from './session.js'; +import { createSession, loadSession, addMessage, getAPIMessages, saveSession } from './session.js'; import { createHookEngine } from './hooks.js'; +import { createContextTracker } from './services/contextTracker.js'; +import { createSkillEngine } from './services/skillEngine.js'; +import { compactIfNeeded, createCompactTracking } from './services/compact.js'; +import { + estimateConversationTokens, + calculateTokenWarnings, +} from './services/tokenEstimation.js'; +import { buildSteeringSections, injectToolSteering } from './services/steering.js'; +import { getAgentTaskTools } from './services/agentFork.js'; +import { getRedactor } from './services/redact.js'; +import { createIntent } from './services/intent.js'; -// Max turns before auto-compaction -const COMPACTION_THRESHOLD = 40; // Max tool execution rounds per user message const MAX_TOOL_ROUNDS = 25; /** - * The Agent — Alfred's core runtime. + * The Agent — Alfred's core runtime (v2). * * @param {Object} provider - AI provider (from providers.js) * @param {Object} opts - Options @@ -29,15 +45,19 @@ const MAX_TOOL_ROUNDS = 25; * @param {string} opts.profile - Hook profile: 'commander' or 'customer' * @param {string} opts.clientId - Customer client ID (for sandbox scoping) * @param {string} opts.workspaceRoot - Customer workspace root dir + * @param {boolean} opts.stream - Enable streaming responses * @param {Function} opts.onText - Callback for text output * @param {Function} opts.onToolUse - Callback for tool execution events + * @param {Function} opts.onToolResult - Callback for tool results * @param {Function} opts.onError - Callback for errors + * @param {Function} opts.onCompactProgress - Callback for compaction progress + * @param {Function} opts.onTokenWarning - Callback for token warnings + * @param {Function} opts.onHookEvent - Callback for hook events */ -export function createAgent(provider, opts = {}) { - const tools = getTools(); +export async function createAgent(provider, opts = {}) { const cwd = opts.cwd || process.cwd(); - // Initialize or resume session + // ── Initialize or resume session ────────────────────────────────── let session; if (opts.sessionId) { session = loadSession(opts.sessionId); @@ -49,7 +69,14 @@ export function createAgent(provider, opts = {}) { session = createSession(); } - const systemPrompt = buildSystemPrompt({ tools, sessionId: session.id, cwd }); + // ── Services ────────────────────────────────────────────────────── + const contextTracker = createContextTracker(opts.workspaceRoot || cwd); + const skillEngine = createSkillEngine(); + const compactTracking = createCompactTracking(); + + // ── Brain services (Omahon pattern ports) ───────────────────────── + const redactor = getRedactor(); + const intent = createIntent(session.id); // Hook engine — gates all tool execution const hookEngine = opts.hookEngine || createHookEngine(opts.profile || 'commander', { @@ -58,7 +85,33 @@ export function createAgent(provider, opts = {}) { onHookEvent: opts.onHookEvent, }); - // Callbacks + // ── Register dynamic tools ──────────────────────────────────────── + const agentTaskTools = getAgentTaskTools(provider, session.id); + for (const tool of agentTaskTools) { + registerTool(tool); + } + const allTools = getTools(); + + // ── Apply steering to tool descriptions ─────────────────────────── + const steeredTools = injectToolSteering(allTools); + + // ── Build system prompt ─────────────────────────────────────────── + const steeringSections = buildSteeringSections(); + const skillListing = skillEngine.getListing(); + const basePromptSections = await buildSystemPrompt({ + tools: steeredTools, + sessionId: session.id, + cwd, + }); + + const systemPrompt = [ + ...basePromptSections, + ...steeringSections, + skillListing, + intent.render(), + ].filter(Boolean); + + // ── Callbacks ───────────────────────────────────────────────────── const onText = opts.onText || (text => process.stdout.write(text)); const onToolUse = opts.onToolUse || ((name, input) => { console.error(`\x1b[36m⚡ Tool: ${name}\x1b[0m`); @@ -68,26 +121,66 @@ export function createAgent(provider, opts = {}) { console.error(`\x1b[32m✓ ${name}: ${preview}\x1b[0m`); }); const onError = opts.onError || (err => console.error(`\x1b[31m✗ Error: ${err}\x1b[0m`)); + const onCompactProgress = opts.onCompactProgress || ((event) => { + if (event.type === 'compact_start') console.error('\x1b[33m📦 Compacting session...\x1b[0m'); + else if (event.type === 'compact_done') console.error(`\x1b[33m📦 Compacted: freed ${event.tokensFreed} tokens\x1b[0m`); + else if (event.type === 'micro_compact') console.error(`\x1b[33m🔬 Micro-compact: freed ${event.tokensFreed} tokens\x1b[0m`); + }); + const onTokenWarning = opts.onTokenWarning || ((warning) => { + if (warning.isWarning) console.error(`\x1b[33m⚠ Context: ${warning.percentUsed}% used (${warning.percentLeft}% left)\x1b[0m`); + }); /** * Process a user message through the agent loop. - * This is the core — the while loop with tools. + * This is the core — the while loop with tools, compaction, and streaming. */ async function processMessage(userMessage) { - // Add user message to session + // ── Add user message to session ─────────────────────────────── addMessage(session, 'user', userMessage); - // Check if we need to compact - if (session.messages.length > COMPACTION_THRESHOLD) { - console.error('\x1b[33m📦 Compacting session...\x1b[0m'); - compactSession(session); + // ── Match skills ────────────────────────────────────────────── + const skillPrompts = skillEngine.getActiveSkillPrompts( + typeof userMessage === 'string' ? userMessage : '', + ); + + const effectiveSystemPrompt = skillPrompts.length > 0 + ? [...systemPrompt, ...skillPrompts] + : systemPrompt; + + // ── Check compaction BEFORE querying ─────────────────────────── + const compactResult = await compactIfNeeded( + session.messages, + provider, + provider.model, + compactTracking, + { + sessionId: session.id, + fileTracker: contextTracker, + activeSkills: skillEngine.getInvokedSkills(), + onProgress: onCompactProgress, + }, + ); + + if (compactResult.wasCompacted) { + session.messages = compactResult.messages; + session.compacted = true; + if (compactResult.summary) session.summary = compactResult.summary; + saveSession(session); + } + + // ── Token warning check ─────────────────────────────────────── + const tokenUsage = estimateConversationTokens(getAPIMessages(session)); + const warnings = calculateTokenWarnings(tokenUsage, provider.model); + if (warnings.isWarning) { + onTokenWarning(warnings); } let round = 0; let lastModel = null; + let lastUsage = null; // ═══════════════════════════════════════════════════════════════ - // THE LOOP — This is it. The agent loop. Simple and powerful. + // THE LOOP — Now with streaming and compaction. // ═══════════════════════════════════════════════════════════════ while (round < MAX_TOOL_ROUNDS) { round++; @@ -95,12 +188,21 @@ export function createAgent(provider, opts = {}) { // 1. Send messages to the AI provider let response; try { - response = await provider.query({ - systemPrompt, + const queryParams = { + systemPrompt: effectiveSystemPrompt, messages: getAPIMessages(session), - tools, + tools: steeredTools, maxTokens: 8192, - }); + }; + + if (opts.stream && provider.streamQuery) { + response = await provider.streamQuery(queryParams, { + onText, + onToolUse: (name) => onToolUse(name, {}), + }); + } else { + response = await provider.query(queryParams); + } } catch (err) { onError(`Provider error: ${err.message}`); break; @@ -109,6 +211,7 @@ export function createAgent(provider, opts = {}) { // Track usage if (response.usage) { session.totalTokensUsed += (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0); + lastUsage = response.usage; } lastModel = response.model || lastModel; @@ -120,38 +223,51 @@ export function createAgent(provider, opts = {}) { for (const block of assistantContent) { if (block.type === 'text') { textParts.push(block.text); - onText(block.text); + if (!opts.stream || !provider.streamQuery) { + onText(block.text); + } } else if (block.type === 'tool_use') { toolUseBlocks.push(block); - onToolUse(block.name, block.input); + if (!opts.stream || !provider.streamQuery) { + onToolUse(block.name, block.input); + } } } // Save assistant response to session addMessage(session, 'assistant', assistantContent); - // 3. If no tool calls, we're done — the model finished its response + // ── Brain: parse ambient tags + track assistant text ─────── + for (const part of textParts) { + intent.parseAmbient(part); + } + + // 3. If no tool calls, we're done if (response.stopReason !== 'tool_use' || toolUseBlocks.length === 0) { break; } - // 4. Execute all tool calls — WITH HOOK GATES + // 4. Execute all tool calls — WITH HOOK GATES + CONTEXT TRACKING const toolResults = []; for (const toolCall of toolUseBlocks) { - // ── PreToolUse Hook ────────────────────────────────── + // ── Context tracking ───────────────────────────────────── + trackToolContext(contextTracker, toolCall.name, toolCall.input); + + // ── Intent tracking (brain) ────────────────────────────── + intent.trackToolUse(toolCall.name, toolCall.input); + + // ── PreToolUse Hook ────────────────────────────────────── const preResult = await hookEngine.runPreToolUse(toolCall.name, toolCall.input); let result; if (preResult.action === 'block') { - // Hook blocked the tool — tell the model why result = { error: `BLOCKED by policy: ${preResult.reason}` }; onError(`Hook blocked ${toolCall.name}: ${preResult.reason}`); } else { - // Use potentially modified input from hooks const finalInput = preResult.input || toolCall.input; result = await executeTool(toolCall.name, finalInput); - // ── PostToolUse Hook ───────────────────────────────── + // ── PostToolUse Hook ───────────────────────────────────── const postResult = await hookEngine.runPostToolUse(toolCall.name, finalInput, result); if (postResult.result !== undefined) { result = postResult.result; @@ -162,28 +278,60 @@ export function createAgent(provider, opts = {}) { toolResults.push({ type: 'tool_result', tool_use_id: toolCall.id, - content: JSON.stringify(result), + content: redactor.redact(JSON.stringify(result)), }); } - // 5. Feed tool results back as user message (Anthropic API format) + // 5. Feed tool results back as user message addMessage(session, 'user', toolResults); - // Loop continues — the model will process tool results and decide - // whether to call more tools or respond to the user + // 6. Check compaction between tool rounds (every 5 rounds) + if (round % 5 === 0) { + const midLoopCompact = await compactIfNeeded( + session.messages, + provider, + provider.model, + compactTracking, + { + sessionId: session.id, + fileTracker: contextTracker, + activeSkills: skillEngine.getInvokedSkills(), + onProgress: onCompactProgress, + }, + ); + if (midLoopCompact.wasCompacted) { + session.messages = midLoopCompact.messages; + session.compacted = true; + saveSession(session); + } + } } if (round >= MAX_TOOL_ROUNDS) { onError(`Hit max tool rounds (${MAX_TOOL_ROUNDS}). Stopping.`); } + compactTracking.turnCounter++; saveSession(session); + // ── Brain: persist intent state ────────────────────────────── + intent.incrementTurn(); + if (lastUsage) { + intent.addTokens((lastUsage.input_tokens || 0) + (lastUsage.output_tokens || 0)); + } + intent.save(); + return { sessionId: session.id, turns: session.turnCount, tokensUsed: session.totalTokensUsed, model: lastModel || provider.model, + context: { + estimatedTokens: estimateConversationTokens(getAPIMessages(session)), + messageCount: session.messages.length, + compacted: session.compacted || false, + filesTracked: contextTracker.getTopFiles(10).length, + }, }; } @@ -191,6 +339,50 @@ export function createAgent(provider, opts = {}) { processMessage, getSession: () => session, getSessionId: () => session.id, - compact: () => compactSession(session), + compact: async () => { + const result = await compactIfNeeded( + session.messages, + provider, + provider.model, + { ...compactTracking, consecutiveFailures: 0 }, + { + sessionId: session.id, + fileTracker: contextTracker, + activeSkills: skillEngine.getInvokedSkills(), + onProgress: onCompactProgress, + }, + ); + if (result.wasCompacted) { + session.messages = result.messages; + session.compacted = true; + saveSession(session); + } + return result; + }, + getContextTracker: () => contextTracker, + getSkillEngine: () => skillEngine, + getTokenUsage: () => estimateConversationTokens(getAPIMessages(session)), + getTokenWarnings: () => calculateTokenWarnings( + estimateConversationTokens(getAPIMessages(session)), + provider.model, + ), }; } + +/** + * Track tool usage in the context tracker. + */ +function trackToolContext(tracker, toolName, input) { + switch (toolName) { + case 'read_file': + if (input.path) tracker.trackRead(input.path); + break; + case 'write_file': + case 'edit_file': + if (input.path) tracker.trackWrite(input.path); + break; + case 'bash': + tracker.trackCommand(input.command || '', input.cwd); + break; + } +} diff --git a/src/cli.js b/src/cli.js index fcc614b..fc46574 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,13 +1,16 @@ #!/usr/bin/env node /** * ═══════════════════════════════════════════════════════════════════════════ - * ALFRED AGENT — Interactive CLI + * ALFRED AGENT — Interactive CLI (v2) * * Usage: * node src/cli.js # New session * node src/cli.js --resume # Resume session * node src/cli.js --sessions # List sessions * node src/cli.js -m "message" # Single message mode + * node src/cli.js --stream # Enable streaming responses + * + * REPL commands: /tokens /context /skills /compact /session /quit /help * ═══════════════════════════════════════════════════════════════════════════ */ import { createInterface } from 'readline'; @@ -25,6 +28,7 @@ for (let i = 0; i < args.length; i++) { else if (args[i] === '--model') flags.model = args[++i]; else if (args[i] === '--provider') flags.provider = args[++i]; else if (args[i] === '--profile') flags.profile = args[++i]; + else if (args[i] === '--stream') flags.stream = true; else if (args[i] === '--help' || args[i] === '-h') flags.help = true; } @@ -42,6 +46,16 @@ if (flags.help) { alfred-agent -s List sessions alfred-agent --model opus Use specific model alfred-agent --provider groq Use specific provider + alfred-agent --stream Stream responses in real-time + + REPL Commands: + /tokens Show token usage and context window warnings + /context Show tracked files and git status + /skills List loaded skills + /compact Force compaction to free context + /session Show current session info + /sessions List recent sessions + /quit Exit Providers: anthropic (default) — Claude (needs ANTHROPIC_API_KEY) @@ -99,10 +113,11 @@ try { } // ── Create agent ───────────────────────────────────────────────────── -const agent = createAgent(provider, { +const agent = await createAgent(provider, { sessionId: flags.resume, cwd: process.cwd(), profile: flags.profile || 'commander', + stream: !!flags.stream, onText: (text) => process.stdout.write(text), onToolUse: (name, input) => { console.error(`\n\x1b[36m\u26a1 ${name}\x1b[0m ${JSON.stringify(input).slice(0, 120)}`); @@ -116,12 +131,20 @@ const agent = createAgent(provider, { console.error(`\x1b[32m✓ ${name}\x1b[0m (${str.length} bytes)`); }, onError: (err) => console.error(`\x1b[31m✗ ${err}\x1b[0m`), + onCompactProgress: (evt) => { + if (evt.type === 'compact_start') console.error('\x1b[33m📦 Compacting session...\x1b[0m'); + else if (evt.type === 'compact_done') console.error(`\x1b[33m📦 Compacted: freed ${evt.tokensFreed} tokens\x1b[0m`); + else if (evt.type === 'micro_compact') console.error(`\x1b[33m🔬 Micro-compact: freed ${evt.tokensFreed} tokens\x1b[0m`); + }, + onTokenWarning: (warning) => { + if (warning.isWarning) console.error(`\x1b[33m⚠ Context: ${warning.percentUsed}% used (${warning.percentLeft}% left)\x1b[0m`); + }, }); // ── Banner ─────────────────────────────────────────────────────────── console.log(` \x1b[36m╔═══════════════════════════════════════════════════════════╗ -║ ALFRED AGENT v1.0.0 — Sovereign AI Runtime ║ +║ ALFRED AGENT v2.0.0 — Sovereign AI Runtime ║ ║ Provider: ${provider.name.padEnd(15)} Model: ${provider.model.padEnd(20)}║ ║ Session: ${agent.getSessionId().padEnd(46)}║ ╚═══════════════════════════════════════════════════════════╝\x1b[0m @@ -164,8 +187,44 @@ rl.on('line', async (line) => { return; } if (input === '/compact') { - agent.compact(); - console.log('Session compacted.'); + try { + const result = await agent.compact(); + console.log(`Session compacted. ${result.wasCompacted ? 'Freed tokens.' : 'No compaction needed.'}`); + } catch (e) { + console.error(`Compact error: ${e.message}`); + } + rl.prompt(); + return; + } + if (input === '/tokens') { + const usage = agent.getTokenUsage(); + const warnings = agent.getTokenWarnings(); + console.log(` Estimated tokens: ${usage}`); + console.log(` Context used: ${warnings.percentUsed}% | Left: ${warnings.percentLeft}%`); + if (warnings.isWarning) console.log(` \x1b[33m⚠ Warning: context window filling up\x1b[0m`); + if (warnings.shouldCompact) console.log(` \x1b[31m⚠ Auto-compact recommended\x1b[0m`); + rl.prompt(); + return; + } + if (input === '/context') { + const snapshot = agent.getContextTracker().getSnapshot(); + console.log(` Top files accessed:`); + for (const f of snapshot.topFiles || []) { + console.log(` ${f.path} (reads:${f.reads}, writes:${f.writes})`); + } + if (snapshot.recentCommands?.length) { + console.log(` Recent commands: ${snapshot.recentCommands.length}`); + } + rl.prompt(); + return; + } + if (input === '/skills') { + const skills = agent.getSkillEngine().getSkills(); + if (skills.length === 0) { + console.log(' No skills loaded. Place SKILL.md files in ~/alfred-agent/data/skills/'); + } else { + for (const s of skills) console.log(` ${s.name} — ${s.description || 'no description'}`); + } rl.prompt(); return; } @@ -181,6 +240,9 @@ rl.on('line', async (line) => { /quit, /exit Exit /session Show current session info /sessions List recent sessions + /tokens Show token usage & context window + /context Show tracked files & git status + /skills List loaded skills /compact Compact session to free context /help This help `); @@ -191,7 +253,9 @@ rl.on('line', async (line) => { try { console.log(); // Blank line before response const result = await agent.processMessage(input); - console.log(`\n\x1b[33m[turn ${result.turns} | ${result.tokensUsed} tokens]\x1b[0m\n`); + const ctx = result.context || {}; + const ctxInfo = ctx.compacted ? ' | compacted' : ''; + console.log(`\n\x1b[33m[turn ${result.turns} | ${result.tokensUsed} tokens | ~${ctx.estimatedTokens || '?'} ctx${ctxInfo}]\x1b[0m\n`); } catch (err) { console.error(`\x1b[31mError: ${err.message}\x1b[0m`); } diff --git a/src/index.js b/src/index.js index 205f40a..3022e1f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ /** * ═══════════════════════════════════════════════════════════════════════════ - * ALFRED AGENT — HTTP Server + * ALFRED AGENT — HTTP Server (v2) * * Exposes the agent harness via HTTP API for integration with: * - Alfred IDE chat panel @@ -8,17 +8,29 @@ * - Voice AI pipeline * - Any internal service * + * New in v2: + * - /chat/stream — Server-Sent Events streaming + * - /context — Context tracker snapshot + * - /tokens — Token usage and warnings + * - /skills — List and reload skills + * - /tasks — Task management endpoints + * - /compact — Manual compaction trigger + * * Binds to 127.0.0.1 only — not exposed to internet. * ═══════════════════════════════════════════════════════════════════════════ */ import { createServer } from 'http'; import { URL } from 'url'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'; import { createAgent } from './agent.js'; import { createAnthropicProvider, createOpenAICompatProvider } from './providers.js'; import { listSessions } from './session.js'; +import { listTasks } from './services/agentFork.js'; const PORT = parseInt(process.env.PORT || process.env.ALFRED_AGENT_PORT || '3102', 10); const HOST = '127.0.0.1'; // Localhost only — never expose to internet +const INTERNAL_SECRET = process.env.INTERNAL_SECRET || 'ee16f048838d22d2c2d54099ea109cd612ed919ddaf1c14b8eb8670214ab0d69'; +const VAULT_DIR = `${process.env.HOME}/.vault/keys`; // Active agents keyed by session ID const agents = new Map(); @@ -38,19 +50,22 @@ function getOrCreateProvider(providerName = 'anthropic', model) { return createAnthropicProvider({ model }); } -function getOrCreateAgent(sessionId, providerName, model) { +async function getOrCreateAgent(sessionId, providerName, model, extraOpts = {}) { if (sessionId && agents.has(sessionId)) return agents.get(sessionId); const provider = getOrCreateProvider(providerName, model); const textChunks = []; const toolEvents = []; - const agent = createAgent(provider, { + const agent = await createAgent(provider, { sessionId, + stream: extraOpts.stream || false, onText: (text) => textChunks.push(text), onToolUse: (name, input) => toolEvents.push({ type: 'tool_use', name, input }), onToolResult: (name, result) => toolEvents.push({ type: 'tool_result', name, result }), onError: (err) => toolEvents.push({ type: 'error', message: err }), + onCompactProgress: (evt) => toolEvents.push({ type: 'compact', ...evt }), + onTokenWarning: (warning) => toolEvents.push({ type: 'token_warning', ...warning }), }); agents.set(agent.getSessionId(), { agent, textChunks, toolEvents }); @@ -60,7 +75,7 @@ function getOrCreateAgent(sessionId, providerName, model) { function sendJSON(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json', - 'X-Alfred-Agent': 'v1.0.0', + 'X-Alfred-Agent': 'v2.0.0', }); res.end(JSON.stringify(data)); } @@ -81,9 +96,10 @@ const server = createServer(async (req, res) => { return sendJSON(res, 200, { status: 'online', agent: 'Alfred Agent Harness', - version: '1.0.0', + version: '2.0.0', activeSessions: agents.size, uptime: process.uptime(), + features: ['compaction', 'streaming', 'skills', 'steering', 'agent-fork', 'context-tracking', 'secret-redaction', 'intent-tracking', 'decay-memory'], }); } @@ -99,7 +115,7 @@ const server = createServer(async (req, res) => { if (!message) return sendJSON(res, 400, { error: 'message is required' }); - const { agent, textChunks, toolEvents } = getOrCreateAgent(sessionId, providerName, model); + const { agent, textChunks, toolEvents } = await getOrCreateAgent(sessionId, providerName, model); // Clear buffers textChunks.length = 0; @@ -117,6 +133,149 @@ const server = createServer(async (req, res) => { }); } + // ── Vault: key management ────────────────────────────────────── + if (path === '/vault/status' && req.method === 'GET') { + const auth = req.headers['x-internal-secret'] || url.searchParams.get('secret'); + if (auth !== INTERNAL_SECRET) return sendJSON(res, 403, { error: 'Forbidden' }); + + const keys = {}; + const providers = ['anthropic', 'openai', 'groq', 'xai']; + for (const name of providers) { + const vaultPath = `${VAULT_DIR}/${name}.key`; + const tmpfsPath = `/run/user/1004/keys/${name}.key`; + try { + const k = readFileSync(vaultPath, 'utf8').trim(); + keys[name] = { status: 'loaded', source: 'vault', prefix: k.substring(0, 12) + '...', length: k.length }; + } catch { + try { + const k = readFileSync(tmpfsPath, 'utf8').trim(); + keys[name] = { status: 'loaded', source: 'tmpfs', prefix: k.substring(0, 12) + '...', length: k.length }; + } catch { + keys[name] = { status: 'missing' }; + } + } + } + return sendJSON(res, 200, { vault: VAULT_DIR, keys }); + } + + if (path === '/vault/set' && req.method === 'POST') { + const auth = req.headers['x-internal-secret']; + if (auth !== INTERNAL_SECRET) return sendJSON(res, 403, { error: 'Forbidden' }); + + const body = await readBody(req); + const { name, key } = JSON.parse(body); + if (!name || !key) return sendJSON(res, 400, { error: 'name and key are required' }); + if (!/^[a-z0-9_-]+$/.test(name)) return sendJSON(res, 400, { error: 'Invalid key name' }); + if (key.length < 10 || key.length > 500) return sendJSON(res, 400, { error: 'Key length must be 10-500 chars' }); + + mkdirSync(VAULT_DIR, { recursive: true, mode: 0o700 }); + const keyPath = `${VAULT_DIR}/${name}.key`; + writeFileSync(keyPath, key.trim() + '\n', { mode: 0o600 }); + return sendJSON(res, 200, { ok: true, saved: keyPath, prefix: key.substring(0, 12) + '...' }); + } + + // ── Chat with streaming (SSE) ───────────────────────────────── + if (path === '/chat/stream' && req.method === 'POST') { + const body = await readBody(req); + const { message, sessionId, provider: providerName, model } = JSON.parse(body); + + if (!message) return sendJSON(res, 400, { error: 'message is required' }); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Alfred-Agent': 'v2.0.0', + }); + + const sendSSE = (event, data) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + const provider = getOrCreateProvider(providerName, model); + const agent = await createAgent(provider, { + sessionId, + stream: true, + onText: (text) => sendSSE('text', { text }), + onToolUse: (name, input) => sendSSE('tool_use', { name, input }), + onToolResult: (name, result) => sendSSE('tool_result', { name, result: JSON.stringify(result).slice(0, 2000) }), + onError: (err) => sendSSE('error', { message: err }), + onCompactProgress: (evt) => sendSSE('compact', evt), + onTokenWarning: (warning) => sendSSE('token_warning', warning), + }); + + agents.set(agent.getSessionId(), { agent, textChunks: [], toolEvents: [] }); + + try { + const result = await agent.processMessage(message); + sendSSE('done', { + sessionId: agent.getSessionId(), + turns: result.turns, + tokensUsed: result.tokensUsed, + model: result.model, + context: result.context, + }); + } catch (err) { + sendSSE('error', { message: err.message }); + } + res.end(); + return; + } + + // ── Context tracker snapshot ──────────────────────────────────── + if (path === '/context' && req.method === 'GET') { + const sid = url.searchParams.get('sessionId'); + if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' }); + const { agent } = agents.get(sid); + return sendJSON(res, 200, agent.getContextTracker().getSnapshot()); + } + + // ── Token usage & warnings ───────────────────────────────────── + if (path === '/tokens' && req.method === 'GET') { + const sid = url.searchParams.get('sessionId'); + if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' }); + const { agent } = agents.get(sid); + return sendJSON(res, 200, { + usage: agent.getTokenUsage(), + warnings: agent.getTokenWarnings(), + }); + } + + // ── Skills — list or reload ──────────────────────────────────── + if (path === '/skills' && req.method === 'GET') { + const sid = url.searchParams.get('sessionId'); + if (sid && agents.has(sid)) { + const { agent } = agents.get(sid); + return sendJSON(res, 200, { skills: agent.getSkillEngine().getSkills() }); + } + return sendJSON(res, 200, { skills: [], note: 'No session specified or session not found' }); + } + + if (path === '/skills/reload' && req.method === 'POST') { + const body = await readBody(req); + const { sessionId: sid } = JSON.parse(body); + if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' }); + const { agent } = agents.get(sid); + agent.getSkillEngine().reload(); + return sendJSON(res, 200, { ok: true, skills: agent.getSkillEngine().getSkills().length }); + } + + // ── Tasks — list or create ───────────────────────────────────── + if (path === '/tasks' && req.method === 'GET') { + const tasks = listTasks(); + return sendJSON(res, 200, { tasks }); + } + + // ── Manual compaction trigger ────────────────────────────────── + if (path === '/compact' && req.method === 'POST') { + const body = await readBody(req); + const { sessionId: sid } = JSON.parse(body); + if (!sid || !agents.has(sid)) return sendJSON(res, 404, { error: 'Session not found' }); + const { agent } = agents.get(sid); + const result = await agent.compact(); + return sendJSON(res, 200, result); + } + // ── 404 ──────────────────────────────────────────────────────── sendJSON(res, 404, { error: 'Not found' }); @@ -138,13 +297,22 @@ function readBody(req) { server.listen(PORT, HOST, () => { console.log(` ╔═══════════════════════════════════════════════════════════╗ -║ ALFRED AGENT SERVER v1.0.0 ║ +║ ALFRED AGENT SERVER v2.0.0 ║ ║ Listening on ${HOST}:${PORT} ║ ║ ║ ║ Endpoints: ║ -║ GET /health — Health check ║ -║ GET /sessions — List sessions ║ -║ POST /chat — Send a message ║ +║ GET /health — Health check + features ║ +║ GET /sessions — List sessions ║ +║ POST /chat — Send a message ║ +║ POST /chat/stream — Streaming SSE chat ║ +║ GET /context — Context tracker snapshot ║ +║ GET /tokens — Token usage & warnings ║ +║ GET /skills — List skills ║ +║ POST /skills/reload — Reload skills from disk ║ +║ GET /tasks — List tasks ║ +║ POST /compact — Manual compaction trigger ║ +║ GET /vault/status — Show loaded API keys ║ +║ POST /vault/set — Store an API key ║ ╚═══════════════════════════════════════════════════════════╝ `); }); diff --git a/src/prompt.js b/src/prompt.js index 1b08773..b66f9f2 100644 --- a/src/prompt.js +++ b/src/prompt.js @@ -15,7 +15,7 @@ const HOME = homedir(); * Build the complete system prompt from layered sections. * Sections are composed dynamically based on context. */ -export function buildSystemPrompt({ tools = [], sessionId = null, cwd = null }) { +export async function buildSystemPrompt({ tools = [], sessionId = null, cwd = null }) { const sections = [ getIdentitySection(), getCommanderSection(), @@ -25,7 +25,7 @@ export function buildSystemPrompt({ tools = [], sessionId = null, cwd = null }) getActionsSection(), getToneSection(), getEnvironmentSection(cwd), - getMemorySection(), + await getMemorySection(), getSessionSection(sessionId), ].filter(Boolean); @@ -137,22 +137,35 @@ function getEnvironmentSection(cwd) { - Runtime: Node.js ${process.version}`; } -function getMemorySection() { +async function getMemorySection() { const memDir = join(HOME, 'alfred-agent', 'data', 'memories'); - if (!existsSync(memDir)) return null; - const files = readdirSync(memDir).filter(f => f.endsWith('.md')); - if (files.length === 0) return null; - - // 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 + const parts = []; -${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) { diff --git a/src/providers.js b/src/providers.js index 4b2f3d8..e524ac4 100644 --- a/src/providers.js +++ b/src/providers.js @@ -1,8 +1,14 @@ /** - * Alfred Agent Harness — Provider Abstraction - * - * Multi-provider support: Anthropic, OpenAI-compat (Groq, xAI, etc.), local Ollama. - * Reads API keys from vault (tmpfs) at runtime — never hardcoded. + * ═══════════════════════════════════════════════════════════════════════════ + * ALFRED AGENT — Provider Abstraction (v2) + * + * Multi-provider support with streaming: + * - Anthropic Claude (streaming + non-streaming) + * - OpenAI-compatible (Groq, xAI, local, etc.) + * + * Reads API keys from vault (tmpfs) at runtime — never hardcoded. + * Built by Commander Danny William Perez and Alfred. + * ═══════════════════════════════════════════════════════════════════════════ */ import Anthropic from '@anthropic-ai/sdk'; import { readFileSync } from 'fs'; @@ -18,7 +24,7 @@ function loadKeyFromVault(name) { return process.env[`${name.toUpperCase()}_API_KEY`] || null; } -/** Anthropic Claude provider */ +/** Anthropic Claude provider — with streaming support */ export function createAnthropicProvider(opts = {}) { const apiKey = opts.apiKey || loadKeyFromVault('anthropic') || process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error('No Anthropic API key found. Set ANTHROPIC_API_KEY or save to /run/user/1004/keys/anthropic.key'); @@ -29,7 +35,9 @@ export function createAnthropicProvider(opts = {}) { return { name: 'anthropic', model, - + client, // Expose client for token counting + + /** Non-streaming query */ async query({ systemPrompt, messages, tools, maxTokens = 8192 }) { const toolDefs = tools.map(t => ({ name: t.name, @@ -52,6 +60,107 @@ export function createAnthropicProvider(opts = {}) { model: response.model, }; }, + + /** + * Streaming query — real-time text output. + * @param {Object} params - Same as query + * @param {Object} callbacks + * @param {Function} callbacks.onText - Called with each text delta + * @param {Function} callbacks.onToolUse - Called when tool use starts + * @param {Function} callbacks.onToolInput - Called with tool input deltas + * @param {Function} callbacks.onComplete - Called with final message + * @returns {Promise} 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, + }; + }, }; } diff --git a/src/services/agentFork.js b/src/services/agentFork.js new file mode 100644 index 0000000..fff3ec8 --- /dev/null +++ b/src/services/agentFork.js @@ -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} { 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, + }; + }, + }, + ]; +} diff --git a/src/services/compact.js b/src/services/compact.js new file mode 100644 index 0000000..069f4be --- /dev/null +++ b/src/services/compact.js @@ -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 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: + + +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 + + +REMINDER: Respond with plain text ONLY — an block followed by a 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} { 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(/[\s\S]*?<\/analysis>/i, ''); + + // Extract summary section + const summaryMatch = formatted.match(/([\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 }; + } +} diff --git a/src/services/contextTracker.js b/src/services/contextTracker.js new file mode 100644 index 0000000..746158b --- /dev/null +++ b/src/services/contextTracker.js @@ -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; }, + }; +} diff --git a/src/services/costTracker.js b/src/services/costTracker.js new file mode 100644 index 0000000..5ec80c3 --- /dev/null +++ b/src/services/costTracker.js @@ -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 }; diff --git a/src/services/decayMemory.js b/src/services/decayMemory.js new file mode 100644 index 0000000..e214ef1 --- /dev/null +++ b/src/services/decayMemory.js @@ -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 = [``]; + 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(`
`); + for (const f of sectionFacts) { + const conf = Math.round(f.confidence * 100); + lines.push(` ${f.content}`); + } + lines.push(`
`); + } + lines.push('
'); + 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, + }; +} diff --git a/src/services/doctor.js b/src/services/doctor.js new file mode 100644 index 0000000..35f7cef --- /dev/null +++ b/src/services/doctor.js @@ -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 }; diff --git a/src/services/intent.js b/src/services/intent.js new file mode 100644 index 0000000..113fa08 --- /dev/null +++ b/src/services/intent.js @@ -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 = ['']; + + if (state.currentTask) { + lines.push(`${state.currentTask}`); + } + if (state.approach) { + lines.push(`${state.approach}`); + } + lines.push(`${state.lifecyclePhase}`); + + if (state.filesRead.length > 0) { + lines.push(``); + // 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(''); + } + + if (state.filesModified.length > 0) { + lines.push(``); + lines.push(state.filesModified.join('\n')); + lines.push(''); + } + + if (state.constraints.length > 0) { + lines.push(''); + state.constraints.forEach(c => lines.push(`- ${c}`)); + lines.push(''); + } + + if (state.failedApproaches.length > 0) { + lines.push(''); + state.failedApproaches.forEach(f => { + lines.push(`- ${f.approach}: ${f.reason}`); + }); + lines.push(''); + } + + if (state.openQuestions.length > 0) { + lines.push(''); + state.openQuestions.forEach(q => lines.push(`- ${q}`)); + lines.push(''); + } + + lines.push(``); + lines.push(''); + + 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, + }; +} diff --git a/src/services/memory.js b/src/services/memory.js new file mode 100644 index 0000000..678416c --- /dev/null +++ b/src/services/memory.js @@ -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 }; diff --git a/src/services/messages.js b/src/services/messages.js new file mode 100644 index 0000000..143da9d --- /dev/null +++ b/src/services/messages.js @@ -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; +} diff --git a/src/services/modelRouter.js b/src/services/modelRouter.js new file mode 100644 index 0000000..a6850a5 --- /dev/null +++ b/src/services/modelRouter.js @@ -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 }; diff --git a/src/services/permissions.js b/src/services/permissions.js new file mode 100644 index 0000000..f9f0a22 --- /dev/null +++ b/src/services/permissions.js @@ -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 (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 }; diff --git a/src/services/redact.js b/src/services/redact.js new file mode 100644 index 0000000..b762e5d --- /dev/null +++ b/src/services/redact.js @@ -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; +} diff --git a/src/services/skillEngine.js b/src/services/skillEngine.js new file mode 100644 index 0000000..7d82703 --- /dev/null +++ b/src/services/skillEngine.js @@ -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} 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); + }); + }, + }; +} diff --git a/src/services/steering.js b/src/services/steering.js new file mode 100644 index 0000000..a497755 --- /dev/null +++ b/src/services/steering.js @@ -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}`, + }; + }); +} diff --git a/src/services/tokenEstimation.js b/src/services/tokenEstimation.js new file mode 100644 index 0000000..1b527e0 --- /dev/null +++ b/src/services/tokenEstimation.js @@ -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} + */ +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); +} diff --git a/src/tools.js b/src/tools.js index bcc8f8e..992e1b2 100644 --- a/src/tools.js +++ b/src/tools.js @@ -450,7 +450,7 @@ registerTool({ registerTool({ name: 'mcp_call', - description: 'Call any tool from the GoCodeMe MCP server (856+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.', + description: 'Call any tool from the GoCodeMe MCP server (875+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.', inputSchema: { type: 'object', properties: { @@ -461,18 +461,16 @@ registerTool({ }, async execute({ tool, args }) { try { - const payload = JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - id: Date.now(), - params: { name: tool, arguments: args || {} }, - }); + const fs = await import('fs'); + const jwtPath = '/run/user/1004/keys/mcp-service.jwt'; + const token = fs.readFileSync(jwtPath, 'utf8').trim(); + const payload = JSON.stringify({ name: tool, arguments: args || {} }); const result = execSync( - `curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`, + `curl -s --max-time 25 -X POST http://127.0.0.1:3006/api/tool -H 'Content-Type: application/json' -H 'Authorization: Bearer ${token}' -d '${payload.replace(/'/g, "'\\''")}'`, { encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 } ); const parsed = JSON.parse(result); - if (parsed.error) return { error: `MCP error: ${parsed.error.message || JSON.stringify(parsed.error)}` }; + if (parsed.error) return { error: `MCP error: ${parsed.error}` }; // Extract content from MCP result format const content = parsed.result?.content; if (Array.isArray(content) && content.length > 0) { @@ -498,37 +496,38 @@ registerTool({ }, async execute({ category, search }) { try { - const result = execSync( - `curl -s http://127.0.0.1:3006/mcp/docs/summary`, - { encoding: 'utf8', timeout: 10000 } - ); - const data = JSON.parse(result); - let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`; - - if (category || search) { - // Get full tool list for filtering - const listResult = execSync( - `curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 })}'`, - { encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024 } + if (search) { + // Use the search endpoint for keyword queries + const searchResult = execSync( + `curl -s --max-time 10 'http://127.0.0.1:3006/mcp/docs/search?q=${encodeURIComponent(search)}'`, + { encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024 } ); - const listData = JSON.parse(listResult); - let tools = listData.result?.tools || []; - - if (category) { - tools = tools.filter(t => (t.category || '').toLowerCase().includes(category.toLowerCase())); - } - if (search) { - const q = search.toLowerCase(); - tools = tools.filter(t => - (t.name || '').toLowerCase().includes(q) || - (t.description || '').toLowerCase().includes(q) - ); - } - summary += `Filtered: ${tools.length} tools\n\n`; + const searchData = JSON.parse(searchResult); + const tools = searchData.results || searchData.tools || []; + let summary = `Search "${search}": ${tools.length} results\n\n`; for (const t of tools.slice(0, 50)) { summary += `• ${t.name}: ${(t.description || '').slice(0, 120)}\n`; } if (tools.length > 50) summary += `\n... and ${tools.length - 50} more`; + return { result: summary }; + } + + const result = execSync( + `curl -s --max-time 10 http://127.0.0.1:3006/mcp/docs/summary`, + { encoding: 'utf8', timeout: 15000 } + ); + const data = JSON.parse(result); + let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`; + + if (category) { + const filtered = (data.categories || []).filter(c => + c.key.toLowerCase().includes(category.toLowerCase()) || + c.label.toLowerCase().includes(category.toLowerCase()) + ); + for (const cat of filtered) { + summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`; + } + if (filtered.length === 0) summary += 'No matching category found.\n'; } else { for (const cat of (data.categories || [])) { summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`; @@ -540,3 +539,150 @@ registerTool({ } }, }); + +// ═══════════════════════════════════════════════════════════════════════ +// BRAIN TOOLS — Decay Memory (Omahon pattern) +// ═══════════════════════════════════════════════════════════════════════ + +import { createMemoryStore } from './services/decayMemory.js'; + +let _memStore = null; +function getMemStore() { + if (!_memStore) _memStore = createMemoryStore(); + return _memStore; +} + +registerTool({ + name: 'memory_store', + description: 'Store a fact in persistent memory with decay-aware confidence. Facts are automatically deduplicated — storing the same content reinforces it instead of duplicating. Use sections to categorize: architecture, conventions, behavior, decisions, constraints, security, api, general.', + inputSchema: { + type: 'object', + properties: { + mind: { type: 'string', description: 'Memory namespace (e.g. project name). Default: "default"' }, + section: { type: 'string', description: 'Category: architecture, conventions, behavior, decisions, constraints, security, api, general' }, + content: { type: 'string', description: 'The fact to remember' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags for filtering' }, + }, + required: ['section', 'content'], + }, + async execute({ mind = 'default', section, content, tags }) { + const store = getMemStore(); + store.ensureMind(mind); + const result = store.store(mind, section, content, { tags }); + return { success: true, ...result }; + }, +}); + +registerTool({ + name: 'memory_recall', + description: 'Recall facts from memory. Returns facts sorted by confidence (most confident first). Low-confidence facts have decayed and may be stale.', + inputSchema: { + type: 'object', + properties: { + mind: { type: 'string', description: 'Memory namespace. Default: "default"' }, + section: { type: 'string', description: 'Filter by section (optional)' }, + minConfidence: { type: 'number', description: 'Minimum confidence threshold (0-1). Default: 0.1' }, + limit: { type: 'number', description: 'Max facts to return. Default: 20' }, + }, + }, + async execute({ mind = 'default', section, minConfidence = 0.1, limit = 20 }) { + const store = getMemStore(); + const facts = store.recall(mind, { section, minConfidence, limit }); + return { facts, count: facts.length }; + }, +}); + +registerTool({ + name: 'memory_search', + description: 'Search memory by keyword. Returns matching facts across all sections.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + mind: { type: 'string', description: 'Memory namespace. Default: "default"' }, + limit: { type: 'number', description: 'Max results. Default: 10' }, + }, + required: ['query'], + }, + async execute({ query, mind = 'default', limit = 10 }) { + const store = getMemStore(); + const results = store.search(query, { mind, limit }); + return { results, count: results.length }; + }, +}); + +registerTool({ + name: 'memory_archive', + description: 'Archive a fact (soft delete). The fact remains in the DB but is no longer recalled.', + inputSchema: { + type: 'object', + properties: { + factId: { type: 'string', description: 'The fact ID to archive' }, + }, + required: ['factId'], + }, + async execute({ factId }) { + const store = getMemStore(); + store.archive(factId); + return { success: true, archived: factId }; + }, +}); + +registerTool({ + name: 'memory_supersede', + description: 'Replace an old fact with a new one. The old fact is marked superseded and the new one links back to it.', + inputSchema: { + type: 'object', + properties: { + oldFactId: { type: 'string', description: 'The fact ID being superseded' }, + mind: { type: 'string', description: 'Memory namespace' }, + section: { type: 'string', description: 'Section for the new fact' }, + content: { type: 'string', description: 'The updated fact content' }, + }, + required: ['oldFactId', 'section', 'content'], + }, + async execute({ oldFactId, mind = 'default', section, content }) { + const store = getMemStore(); + const result = store.supersede(oldFactId, mind, section, content); + return { success: true, ...result }; + }, +}); + +registerTool({ + name: 'memory_connect', + description: 'Create a relation between two facts. Relations: depends_on, contradicts, elaborates, implements, supersedes.', + inputSchema: { + type: 'object', + properties: { + sourceId: { type: 'string', description: 'Source fact ID' }, + targetId: { type: 'string', description: 'Target fact ID' }, + relation: { type: 'string', description: 'Relation type: depends_on, contradicts, elaborates, implements, supersedes' }, + description: { type: 'string', description: 'Optional description of the relation' }, + }, + required: ['sourceId', 'targetId', 'relation'], + }, + async execute({ sourceId, targetId, relation, description }) { + const store = getMemStore(); + const edgeId = store.connect(sourceId, targetId, relation, description); + return { success: true, edgeId }; + }, +}); + +registerTool({ + name: 'memory_episode', + description: 'Record an episode — a narrative summary of a work session or event. Episodes tie together related facts into a story.', + inputSchema: { + type: 'object', + properties: { + mind: { type: 'string', description: 'Memory namespace. Default: "default"' }, + title: { type: 'string', description: 'Episode title' }, + narrative: { type: 'string', description: 'Narrative summary of what happened' }, + }, + required: ['title', 'narrative'], + }, + async execute({ mind = 'default', title, narrative }) { + const store = getMemStore(); + const id = store.recordEpisode(mind, title, narrative); + return { success: true, episodeId: id }; + }, +}); diff --git a/upgrade.sh b/upgrade.sh new file mode 100755 index 0000000..77099f6 --- /dev/null +++ b/upgrade.sh @@ -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" </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