@cachly-dev/mcp-server 0.10.65 → 0.10.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/handlers/brain.js +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +7 -1
- package/server.json +2 -2
- package/.env.test.example +0 -23
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -50
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -25
- package/.github/workflows/eas-build-android.yml +0 -58
- package/.github/workflows/eas-update.yml +0 -55
- package/.github/workflows/publish.yml +0 -72
- package/BENCH.md +0 -113
- package/CHANGELOG.md +0 -653
- package/Dockerfile +0 -16
- package/LAUNCH_TEXTS.md +0 -182
- package/PROGRESS.md +0 -141
- package/STRATEGY.md +0 -228
- package/VISION_10X.md +0 -133
- package/apps/travel-chaos-organizer/.env.example +0 -49
- package/apps/travel-chaos-organizer/DEPLOY.md +0 -328
- package/apps/travel-chaos-organizer/Makefile +0 -57
- package/apps/travel-chaos-organizer/README.md +0 -159
- package/apps/travel-chaos-organizer/backend/.env.example +0 -10
- package/apps/travel-chaos-organizer/backend/Dockerfile +0 -25
- package/apps/travel-chaos-organizer/backend/alembic/env.py +0 -56
- package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +0 -24
- package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +0 -24
- package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +0 -24
- package/apps/travel-chaos-organizer/backend/alembic.ini +0 -39
- package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +0 -81
- package/apps/travel-chaos-organizer/backend/app/config.py +0 -66
- package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/db/database.py +0 -20
- package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +0 -71
- package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +0 -31
- package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +0 -4
- package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +0 -5
- package/apps/travel-chaos-organizer/backend/app/limiter.py +0 -4
- package/apps/travel-chaos-organizer/backend/app/logging_config.py +0 -25
- package/apps/travel-chaos-organizer/backend/app/main.py +0 -77
- package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/models/schemas.py +0 -142
- package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/routers/admin.py +0 -237
- package/apps/travel-chaos-organizer/backend/app/routers/cache.py +0 -13
- package/apps/travel-chaos-organizer/backend/app/routers/events.py +0 -29
- package/apps/travel-chaos-organizer/backend/app/routers/export.py +0 -122
- package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +0 -122
- package/apps/travel-chaos-organizer/backend/app/routers/items.py +0 -130
- package/apps/travel-chaos-organizer/backend/app/routers/mail.py +0 -108
- package/apps/travel-chaos-organizer/backend/app/routers/parse.py +0 -303
- package/apps/travel-chaos-organizer/backend/app/routers/payments.py +0 -179
- package/apps/travel-chaos-organizer/backend/app/routers/trips.py +0 -134
- package/apps/travel-chaos-organizer/backend/app/routers/users.py +0 -169
- package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +0 -37
- package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/services/cache.py +0 -131
- package/apps/travel-chaos-organizer/backend/app/services/drip.py +0 -128
- package/apps/travel-chaos-organizer/backend/app/services/email.py +0 -61
- package/apps/travel-chaos-organizer/backend/app/services/errors.py +0 -44
- package/apps/travel-chaos-organizer/backend/app/services/notifier.py +0 -10
- package/apps/travel-chaos-organizer/backend/app/services/ollama.py +0 -101
- package/apps/travel-chaos-organizer/backend/app/services/parser.py +0 -22
- package/apps/travel-chaos-organizer/backend/app/services/quota.py +0 -85
- package/apps/travel-chaos-organizer/backend/app/services/sentry.py +0 -11
- package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +0 -35
- package/apps/travel-chaos-organizer/backend/pytest.ini +0 -4
- package/apps/travel-chaos-organizer/backend/requirements-dev.txt +0 -6
- package/apps/travel-chaos-organizer/backend/requirements.txt +0 -25
- package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/tests/conftest.py +0 -223
- package/apps/travel-chaos-organizer/backend/tests/test_admin.py +0 -99
- package/apps/travel-chaos-organizer/backend/tests/test_cache.py +0 -142
- package/apps/travel-chaos-organizer/backend/tests/test_drip.py +0 -32
- package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +0 -121
- package/apps/travel-chaos-organizer/backend/tests/test_export.py +0 -48
- package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +0 -107
- package/apps/travel-chaos-organizer/backend/tests/test_items.py +0 -138
- package/apps/travel-chaos-organizer/backend/tests/test_mail.py +0 -113
- package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +0 -145
- package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +0 -98
- package/apps/travel-chaos-organizer/backend/tests/test_parser.py +0 -72
- package/apps/travel-chaos-organizer/backend/tests/test_payments.py +0 -163
- package/apps/travel-chaos-organizer/backend/tests/test_quota.py +0 -46
- package/apps/travel-chaos-organizer/backend/tests/test_search.py +0 -67
- package/apps/travel-chaos-organizer/backend/tests/test_trips.py +0 -123
- package/apps/travel-chaos-organizer/backend/tests/test_users.py +0 -52
- package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/caddy/Caddyfile +0 -27
- package/apps/travel-chaos-organizer/docker-compose.node1.yml +0 -31
- package/apps/travel-chaos-organizer/docker-compose.override.yml +0 -14
- package/apps/travel-chaos-organizer/docker-compose.yml +0 -128
- package/apps/travel-chaos-organizer/frontend/.env.example +0 -13
- package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +0 -80
- package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +0 -166
- package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +0 -130
- package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +0 -381
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +0 -346
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +0 -363
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +0 -34
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +0 -35
- package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +0 -91
- package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +0 -71
- package/apps/travel-chaos-organizer/frontend/app/auth.tsx +0 -29
- package/apps/travel-chaos-organizer/frontend/app/index.tsx +0 -16
- package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +0 -119
- package/apps/travel-chaos-organizer/frontend/app.config.ts +0 -54
- package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +0 -66
- package/apps/travel-chaos-organizer/frontend/assets/icon.svg +0 -79
- package/apps/travel-chaos-organizer/frontend/assets/splash.svg +0 -15
- package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +0 -102
- package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +0 -107
- package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +0 -109
- package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +0 -45
- package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +0 -19
- package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +0 -155
- package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +0 -55
- package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +0 -80
- package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +0 -39
- package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +0 -39
- package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +0 -121
- package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +0 -73
- package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +0 -130
- package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +0 -60
- package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +0 -42
- package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +0 -146
- package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +0 -44
- package/apps/travel-chaos-organizer/frontend/eas.json +0 -22
- package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +0 -24
- package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +0 -60
- package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +0 -112
- package/apps/travel-chaos-organizer/frontend/lib/api.ts +0 -223
- package/apps/travel-chaos-organizer/frontend/lib/auth.ts +0 -93
- package/apps/travel-chaos-organizer/frontend/lib/db.ts +0 -86
- package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +0 -54
- package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +0 -13
- package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +0 -112
- package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +0 -145
- package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +0 -43
- package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +0 -28
- package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +0 -44
- package/apps/travel-chaos-organizer/frontend/package-lock.json +0 -13221
- package/apps/travel-chaos-organizer/frontend/package.json +0 -44
- package/apps/travel-chaos-organizer/frontend/tsconfig.json +0 -23
- package/apps/travel-chaos-organizer/keycloak/realm-export.json +0 -59
- package/apps/travel-chaos-organizer/landing/index.html +0 -773
- package/apps/travel-chaos-organizer/scripts/backup.sh +0 -14
- package/apps/travel-chaos-organizer/scripts/configure-env.sh +0 -82
- package/apps/travel-chaos-organizer/scripts/deploy.sh +0 -54
- package/apps/travel-chaos-organizer/scripts/health-check.sh +0 -36
- package/apps/travel-chaos-organizer/scripts/migrate.sh +0 -7
- package/apps/travel-chaos-organizer/scripts/server-setup.sh +0 -85
- package/apps/travel-chaos-organizer/scripts/smoke-test.sh +0 -29
- package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +0 -71
- package/apps/travel-chaos-organizer/telegram-bot/.env.example +0 -3
- package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +0 -6
- package/apps/travel-chaos-organizer/telegram-bot/bot.py +0 -504
- package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +0 -3
- package/dist/src/__tests__/auth.test.d.ts +0 -1
- package/dist/src/__tests__/auth.test.js +0 -107
- package/dist/src/__tests__/auth.test.js.map +0 -1
- package/dist/src/__tests__/brain-flow.test.d.ts +0 -12
- package/dist/src/__tests__/brain-flow.test.js +0 -1241
- package/dist/src/__tests__/brain-flow.test.js.map +0 -1
- package/dist/src/__tests__/confidence.test.d.ts +0 -13
- package/dist/src/__tests__/confidence.test.js +0 -233
- package/dist/src/__tests__/confidence.test.js.map +0 -1
- package/dist/src/__tests__/e2e/api-auth.test.d.ts +0 -7
- package/dist/src/__tests__/e2e/api-auth.test.js +0 -162
- package/dist/src/__tests__/e2e/api-auth.test.js.map +0 -1
- package/dist/src/__tests__/e2e/api-health.test.d.ts +0 -7
- package/dist/src/__tests__/e2e/api-health.test.js +0 -95
- package/dist/src/__tests__/e2e/api-health.test.js.map +0 -1
- package/dist/src/__tests__/e2e/cli.test.d.ts +0 -10
- package/dist/src/__tests__/e2e/cli.test.js +0 -204
- package/dist/src/__tests__/e2e/cli.test.js.map +0 -1
- package/dist/src/__tests__/e2e/config.d.ts +0 -35
- package/dist/src/__tests__/e2e/config.js +0 -53
- package/dist/src/__tests__/e2e/config.js.map +0 -1
- package/dist/src/__tests__/e2e/funnel.test.d.ts +0 -13
- package/dist/src/__tests__/e2e/funnel.test.js +0 -90
- package/dist/src/__tests__/e2e/funnel.test.js.map +0 -1
- package/dist/src/__tests__/e2e/mcp-tools.test.d.ts +0 -7
- package/dist/src/__tests__/e2e/mcp-tools.test.js +0 -129
- package/dist/src/__tests__/e2e/mcp-tools.test.js.map +0 -1
- package/dist/src/__tests__/e2e/provisioning.test.d.ts +0 -15
- package/dist/src/__tests__/e2e/provisioning.test.js +0 -123
- package/dist/src/__tests__/e2e/provisioning.test.js.map +0 -1
- package/dist/src/__tests__/handlers-unit.test.d.ts +0 -12
- package/dist/src/__tests__/handlers-unit.test.js +0 -505
- package/dist/src/__tests__/handlers-unit.test.js.map +0 -1
- package/dist/src/__tests__/namespace.test.d.ts +0 -1
- package/dist/src/__tests__/namespace.test.js +0 -114
- package/dist/src/__tests__/namespace.test.js.map +0 -1
- package/dist/src/__tests__/rerank.test.d.ts +0 -1
- package/dist/src/__tests__/rerank.test.js +0 -125
- package/dist/src/__tests__/rerank.test.js.map +0 -1
- package/dist/src/__tests__/utils.test.d.ts +0 -1
- package/dist/src/__tests__/utils.test.js +0 -83
- package/dist/src/__tests__/utils.test.js.map +0 -1
- package/dist/src/bench/cachly-bench.d.ts +0 -39
- package/dist/src/bench/cachly-bench.js +0 -223
- package/dist/src/bench/cachly-bench.js.map +0 -1
- package/dist/src/bench/fixtures.d.ts +0 -37
- package/dist/src/bench/fixtures.js +0 -225
- package/dist/src/bench/fixtures.js.map +0 -1
- package/dist/src/search.test.d.ts +0 -1
- package/dist/src/search.test.js +0 -713
- package/dist/src/search.test.js.map +0 -1
- package/eslint.config.js +0 -16
- package/glama.json +0 -36
- package/llms.txt +0 -155
- package/packages/telegram-notify/SPLIT_PLAN.md +0 -106
- package/packages/telegram-notify/client.ts +0 -88
- package/packages/telegram-notify/emojis.json +0 -22
- package/packages/telegram-notify/pyproject.toml +0 -17
- package/packages/telegram-notify/telegram_notify/__init__.py +0 -3
- package/packages/telegram-notify/telegram_notify/client.py +0 -87
- package/packages/telegram-notify/telegram_notify/emojis.json +0 -22
- package/scripts/e2e-run.sh +0 -160
- package/scripts/generate-icons.js +0 -28
- package/smithery.yaml +0 -45
- package/src/__tests__/auth.test.ts +0 -147
- package/src/__tests__/brain-flow.test.ts +0 -1472
- package/src/__tests__/confidence.test.ts +0 -285
- package/src/__tests__/e2e/api-auth.test.ts +0 -182
- package/src/__tests__/e2e/api-health.test.ts +0 -103
- package/src/__tests__/e2e/cli.test.ts +0 -228
- package/src/__tests__/e2e/config.ts +0 -53
- package/src/__tests__/e2e/funnel.test.ts +0 -99
- package/src/__tests__/e2e/mcp-tools.test.ts +0 -191
- package/src/__tests__/e2e/provisioning.test.ts +0 -128
- package/src/__tests__/handlers-unit.test.ts +0 -677
- package/src/__tests__/namespace.test.ts +0 -126
- package/src/__tests__/rerank.test.ts +0 -141
- package/src/__tests__/utils.test.ts +0 -81
- package/src/auth.ts +0 -46
- package/src/bench/cachly-bench.ts +0 -263
- package/src/bench/fixtures.ts +0 -256
- package/src/ckg.ts +0 -103
- package/src/confidence.ts +0 -38
- package/src/embeddings.ts +0 -217
- package/src/handlers/advanced.ts +0 -647
- package/src/handlers/brain.ts +0 -2622
- package/src/handlers/cache.ts +0 -633
- package/src/handlers/context.ts +0 -222
- package/src/handlers/fedbrain.ts +0 -1232
- package/src/handlers/instances.ts +0 -180
- package/src/handlers/roadmap.ts +0 -201
- package/src/handlers/syndicate.ts +0 -460
- package/src/handlers/tco.ts +0 -345
- package/src/handlers/team.ts +0 -1249
- package/src/index.ts +0 -2815
- package/src/namespace.ts +0 -128
- package/src/notifier.ts +0 -5
- package/src/rerank.ts +0 -148
- package/src/search.test.ts +0 -848
- package/src/search.ts +0 -1781
- package/src/tools.ts +0 -2056
- package/src/utils.ts +0 -90
- package/test-features.cjs +0 -23
- package/tsconfig.json +0 -16
- package/vitest.config.ts +0 -14
package/src/handlers/brain.ts
DELETED
|
@@ -1,2622 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
2
|
-
import type { Redis } from 'ioredis';
|
|
3
|
-
import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
|
|
4
|
-
CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
|
|
5
|
-
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
|
|
6
|
-
ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
|
|
7
|
-
import { safeJsonParse, scanKeys } from '../utils.js';
|
|
8
|
-
import type { CKGEdge, CKGNode, PersonNode } from '../ckg.js';
|
|
9
|
-
import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
|
|
10
|
-
indexVocab as _indexVocab } from '../search.js';
|
|
11
|
-
import { rerankByQuality } from '../rerank.js';
|
|
12
|
-
import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
|
|
13
|
-
|
|
14
|
-
// ── Changelog (shown once per version in session_start) ──────────────────────
|
|
15
|
-
const MCP_VERSION = '0.10.65';
|
|
16
|
-
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
-
'0.10.64': [
|
|
18
|
-
`📈 **The three decisive metrics, now measurable**`,
|
|
19
|
-
` ⏱️ \`brain_metrics()\` — time-to-first-recall, recall-lift, team-knowledge-reuse in one view`,
|
|
20
|
-
` 👥 Cross-author reuse tracked: recall a teammate's lesson → counted + surfaced inline`,
|
|
21
|
-
` 🛡️ Stability: scans now capped + timed out (a huge keyspace can't hang the agent)`,
|
|
22
|
-
],
|
|
23
|
-
'0.10.61': [
|
|
24
|
-
`🎯 **Phase 3C: 100 MCP tools milestone — zero-setup knowledge graph**`,
|
|
25
|
-
` 🔄 \`brain_from_git\` now auto-builds Person+File nodes from git history (zero setup!)`,
|
|
26
|
-
` 🔍 \`skill_gaps()\` — find domains with unresolved failures, missing attribution`,
|
|
27
|
-
` 📊 \`brain_coverage()\` — scored 0-100 health report: lessons · attribution · file coverage`,
|
|
28
|
-
],
|
|
29
|
-
'0.10.60': [
|
|
30
|
-
`🆕 **Phase 3B: File map + team overview + visibility**`,
|
|
31
|
-
` 📁 \`brain_file_map(file_paths=[...])\` — experts + lessons per file before you touch it`,
|
|
32
|
-
` 🗺️ \`team_expertise_map()\` — full team skills matrix in one table`,
|
|
33
|
-
` 🔒 \`visibility: "private"\` on \`learn_from_attempts\` — private notes never leak to smart_recall`,
|
|
34
|
-
` 📊 98 MCP tools`,
|
|
35
|
-
],
|
|
36
|
-
'0.10.59': [
|
|
37
|
-
`🆕 **Phase 3A: Org-wide knowledge graph**`,
|
|
38
|
-
` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
|
|
39
|
-
` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
|
|
40
|
-
` 👤 Author attribution now shown inline in \`smart_recall\` results`,
|
|
41
|
-
` 📊 96 MCP tools · 386 tests green`,
|
|
42
|
-
],
|
|
43
|
-
'0.10.58': [
|
|
44
|
-
`🧹 Zero lint warnings — all unused imports cleaned across every handler`,
|
|
45
|
-
`✅ Build: 0 errors, 0 warnings · 379 tests green`,
|
|
46
|
-
],
|
|
47
|
-
'0.10.57': [
|
|
48
|
-
`🆕 **What's new in v${MCP_VERSION}:**`,
|
|
49
|
-
` 🔧 **Critical fix** — corrected the npm bin/main entry path (\`dist/src/index.js\`); 0.10.50–0.10.52 shipped a broken entry point`,
|
|
50
|
-
` 🔧 Version hygiene — synced package.json, server.json, lockfile, MCP_VERSION; \`latest\` now points forward again`,
|
|
51
|
-
` ✅ Accurate tool count (95) across README, server.json, CLI banners`,
|
|
52
|
-
` ✅ Includes all Phase 2/3 work: quality reranking, Cachly-Bench, hybrid + CKG recall, team_confirm governance`,
|
|
53
|
-
],
|
|
54
|
-
'0.10.52': [
|
|
55
|
-
` ✅ \`smart_recall\` — CKG traversal as Layer 3: finds lessons that FIXED causal-graph-similar problems (🕸️ causal graph badge)`,
|
|
56
|
-
` ✅ Bench corpus expanded: governance adversarial pair proves review-boost end-to-end (+22% P@1 headline)`,
|
|
57
|
-
` 🔧 Import cleanup: unused symbols removed from brain.ts (no functional change)`,
|
|
58
|
-
` 💡 \`smart_recall\` now shows keyword / semantic / CKG / hybrid match type for every result`,
|
|
59
|
-
],
|
|
60
|
-
'0.10.51': [
|
|
61
|
-
` ✅ \`team_confirm\` — human review raises lesson recall ranking (🛡️ senior / ✔️ peer)`,
|
|
62
|
-
` ✅ Governance-aware recall — confirmed lessons outrank unreviewed auto-learned entries`,
|
|
63
|
-
],
|
|
64
|
-
'0.10.50': [
|
|
65
|
-
` ✅ \`smart_recall\` — unified keyword + semantic hybrid list (one ranked result, not two sections)`,
|
|
66
|
-
` ✅ \`smart_recall\` — "Brain saved you here" banner surfaces time saved inline per proven lesson`,
|
|
67
|
-
` ✅ Quality reranking — proven successes outrank symptom-dense failures (+11% Precision@1)`,
|
|
68
|
-
` ✅ Cachly-Bench — IR benchmark with CI regression guard (see BENCH.md for the proof)`,
|
|
69
|
-
` 🔧 Contradiction resolution now persisted to Redis (90-day TTL)`,
|
|
70
|
-
` 💡 Run \`smart_recall\` to see your Brain's recall quality in action`,
|
|
71
|
-
],
|
|
72
|
-
'0.10.49': [
|
|
73
|
-
` ✅ \`brain_from_git\` — auto-seed your Brain from git history in seconds`,
|
|
74
|
-
` ✅ \`brain_predict_failures\` — predict CI/build failures before they happen`,
|
|
75
|
-
` ✅ VS Code extension — Brain status bar, ambient learning, CodeLens hints`,
|
|
76
|
-
` ✅ Team Brain — share lessons across your whole engineering team`,
|
|
77
|
-
` 🔧 Stability — auto-expiring memory keys, longer provisioning wait, robust recall`,
|
|
78
|
-
],
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Shared types used in brain handlers
|
|
82
|
-
export interface Instance {
|
|
83
|
-
id: string; name: string; tier: string; status: string; region: string;
|
|
84
|
-
host?: string; port?: number; password?: string; tls_enabled?: boolean;
|
|
85
|
-
vector_token?: string; memory_mb: number; encryption_at_rest: boolean;
|
|
86
|
-
created_at: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
interface SemanticSearchResponse {
|
|
90
|
-
found: boolean;
|
|
91
|
-
id?: string;
|
|
92
|
-
similarity?: number;
|
|
93
|
-
prompt?: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
97
|
-
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
98
|
-
|
|
99
|
-
export const BRAIN_TOOL_NAMES = new Set([
|
|
100
|
-
'learn_from_attempts', 'recall_best_solution', 'smart_recall',
|
|
101
|
-
'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
|
|
102
|
-
'brain_who_knows', 'brain_file_map', 'team_expertise_map',
|
|
103
|
-
'skill_gaps', 'brain_coverage', 'brain_metrics',
|
|
104
|
-
]);
|
|
105
|
-
|
|
106
|
-
export async function handleBrainTool(
|
|
107
|
-
name: string,
|
|
108
|
-
args: Record<string, unknown>,
|
|
109
|
-
getConnection: GetConnection,
|
|
110
|
-
apiFetch: ApiFetch,
|
|
111
|
-
): Promise<string | null> {
|
|
112
|
-
switch (name) {
|
|
113
|
-
case 'learn_from_attempts': {
|
|
114
|
-
const {
|
|
115
|
-
instance_id,
|
|
116
|
-
topic,
|
|
117
|
-
outcome,
|
|
118
|
-
what_worked,
|
|
119
|
-
what_failed = '',
|
|
120
|
-
context: ctx = '',
|
|
121
|
-
severity = 'major',
|
|
122
|
-
file_paths = [],
|
|
123
|
-
commands = [],
|
|
124
|
-
tags = [],
|
|
125
|
-
depends_on = [],
|
|
126
|
-
author = '',
|
|
127
|
-
visibility = 'team',
|
|
128
|
-
} = args as {
|
|
129
|
-
instance_id: string;
|
|
130
|
-
topic: string;
|
|
131
|
-
outcome: 'success' | 'failure' | 'partial';
|
|
132
|
-
what_worked: string;
|
|
133
|
-
what_failed?: string;
|
|
134
|
-
context?: string;
|
|
135
|
-
severity?: 'critical' | 'major' | 'minor';
|
|
136
|
-
file_paths?: string[];
|
|
137
|
-
commands?: string[];
|
|
138
|
-
tags?: string[];
|
|
139
|
-
depends_on?: string[];
|
|
140
|
-
author?: string;
|
|
141
|
-
visibility?: 'public' | 'team' | 'private';
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const redis = await getConnection(instance_id);
|
|
145
|
-
const ts = new Date().toISOString();
|
|
146
|
-
|
|
147
|
-
// ── Structured template hints ──────────────────────────────────────────
|
|
148
|
-
const category = topic.split(':')[0];
|
|
149
|
-
const template = STRUCTURED_TEMPLATES[category];
|
|
150
|
-
const templateWarnings: string[] = [];
|
|
151
|
-
if (template) {
|
|
152
|
-
for (const req of template.required) {
|
|
153
|
-
if (req === 'commands' && commands.length === 0) {
|
|
154
|
-
templateWarnings.push(`📋 ${template.hint}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── Deduplication + audit trail ────────────────────────────────────────
|
|
160
|
-
let isUpdate = false;
|
|
161
|
-
let recallCount = 0;
|
|
162
|
-
let auditTrail: Array<{ ts: string; action: string; prev_outcome?: string }> = [];
|
|
163
|
-
const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
|
|
164
|
-
if (existingRaw) {
|
|
165
|
-
try {
|
|
166
|
-
const prev = JSON.parse(existingRaw) as {
|
|
167
|
-
recall_count?: number;
|
|
168
|
-
outcome?: string;
|
|
169
|
-
audit_trail?: Array<{ ts: string; action: string; prev_outcome?: string }>;
|
|
170
|
-
};
|
|
171
|
-
recallCount = prev.recall_count ?? 0;
|
|
172
|
-
auditTrail = prev.audit_trail ?? [];
|
|
173
|
-
auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
|
|
174
|
-
if (auditTrail.length > 20) auditTrail = auditTrail.slice(-20);
|
|
175
|
-
isUpdate = true;
|
|
176
|
-
|
|
177
|
-
// ── Contradiction detection ─────────────────────────────────────────
|
|
178
|
-
const contradictionWarning: string[] = [];
|
|
179
|
-
if (prev.outcome === 'success' && outcome === 'failure') {
|
|
180
|
-
contradictionWarning.push(
|
|
181
|
-
`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`,
|
|
182
|
-
`The existing "success" lesson will be preserved. Only the audit trail is updated.`,
|
|
183
|
-
`If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`,
|
|
184
|
-
);
|
|
185
|
-
} else if (prev.outcome === 'failure' && outcome === 'success') {
|
|
186
|
-
contradictionWarning.push(
|
|
187
|
-
`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
if (contradictionWarning.length > 0) {
|
|
191
|
-
// Store contradiction audit but don't block
|
|
192
|
-
auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
|
|
193
|
-
// Layer 3: Write CKG contradicts edge for MADC to process
|
|
194
|
-
try {
|
|
195
|
-
const cId = ckgSlug(topic);
|
|
196
|
-
const resId = ckgSlug(`resolution:${topic}`);
|
|
197
|
-
await ckgUpdateEdge(redis, cId, 'contradicts', resId, false);
|
|
198
|
-
} catch { /* non-critical */ }
|
|
199
|
-
// Persist a queryable resolution record — so contradiction history survives
|
|
200
|
-
// (CKG edges alone aren't easily auditable). Bounded + TTL'd like other history.
|
|
201
|
-
try {
|
|
202
|
-
const resolutionKey = `cachly:contradictions:${topic}`;
|
|
203
|
-
const direction = prev.outcome === 'success' && outcome === 'failure'
|
|
204
|
-
? 'kept-success' : 'overwrote-failure';
|
|
205
|
-
await redis.rpush(resolutionKey, JSON.stringify({
|
|
206
|
-
ts, prev_outcome: prev.outcome, new_outcome: outcome, direction,
|
|
207
|
-
}));
|
|
208
|
-
await redis.ltrim(resolutionKey, -50, -1);
|
|
209
|
-
await redis.expire(resolutionKey, 180 * 86400);
|
|
210
|
-
} catch { /* non-critical */ }
|
|
211
|
-
contradictionWarning.push(`🗳️ Run \`madc_deliberate(topic="${topic}")\` to resolve via expert agent voting.`);
|
|
212
|
-
}
|
|
213
|
-
} catch { /* ignore parse error */ }
|
|
214
|
-
} else {
|
|
215
|
-
auditTrail = [{ ts, action: 'created' }];
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ── "I Was Wrong" Protocol — failure attribution ───────────────────────
|
|
219
|
-
const iWasWrongWarning: string[] = [];
|
|
220
|
-
if (outcome === 'failure') {
|
|
221
|
-
// Search for related success lessons that might have prevented this failure
|
|
222
|
-
const scanKeys: string[] = [];
|
|
223
|
-
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
224
|
-
await new Promise<void>((res, rej) => {
|
|
225
|
-
scanStream.on('data', (b: string[]) => scanKeys.push(...b));
|
|
226
|
-
scanStream.on('end', res);
|
|
227
|
-
scanStream.on('error', rej);
|
|
228
|
-
});
|
|
229
|
-
const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
|
|
230
|
-
for (const k of scanKeys.slice(0, 50)) {
|
|
231
|
-
const raw = await redis.get(k);
|
|
232
|
-
if (!raw) continue;
|
|
233
|
-
try {
|
|
234
|
-
const l = JSON.parse(raw) as { outcome?: string; topic?: string; severity?: string };
|
|
235
|
-
if (l.outcome !== 'success') continue;
|
|
236
|
-
const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
|
|
237
|
-
const overlap = topicWords.filter(w => lWords.includes(w)).length;
|
|
238
|
-
if (overlap >= 1 && l.topic !== topic) {
|
|
239
|
-
iWasWrongWarning.push(
|
|
240
|
-
`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
|
|
241
|
-
` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
|
|
242
|
-
` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
|
|
243
|
-
);
|
|
244
|
-
break; // only show the most relevant match
|
|
245
|
-
}
|
|
246
|
-
} catch { /* skip */ }
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── Register dependency index for causal chain ─────────────────────────
|
|
251
|
-
for (const dep of depends_on) {
|
|
252
|
-
const depKey = `cachly:dep:${dep}`;
|
|
253
|
-
const existing = await redis.get(depKey);
|
|
254
|
-
const depTopics: string[] = safeJsonParse<string[]>(existing, []);
|
|
255
|
-
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
256
|
-
await redis.set(depKey, JSON.stringify(depTopics), 'EX', 90 * 86400);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const lessonObj = {
|
|
260
|
-
topic,
|
|
261
|
-
outcome,
|
|
262
|
-
what_worked,
|
|
263
|
-
what_failed,
|
|
264
|
-
context: ctx,
|
|
265
|
-
severity,
|
|
266
|
-
file_paths,
|
|
267
|
-
commands,
|
|
268
|
-
tags,
|
|
269
|
-
depends_on,
|
|
270
|
-
...(author ? { author } : {}),
|
|
271
|
-
visibility,
|
|
272
|
-
recall_count: recallCount,
|
|
273
|
-
ts,
|
|
274
|
-
verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
|
|
275
|
-
confidence: 1.0,
|
|
276
|
-
audit_trail: auditTrail,
|
|
277
|
-
version: 3,
|
|
278
|
-
};
|
|
279
|
-
const lesson = JSON.stringify(lessonObj);
|
|
280
|
-
|
|
281
|
-
// Metric 1 (time-to-first-recall): mark the moment the Brain first gained
|
|
282
|
-
// knowledge. SET NX so only the very first learn wins. Fire-and-forget.
|
|
283
|
-
redis.set(`cachly:stats:born_at:${instance_id}`, ts, 'EX', 365 * 86400, 'NX').catch(() => {});
|
|
284
|
-
|
|
285
|
-
// Always append to the history list (audit log); keep last 100 entries, 90-day TTL
|
|
286
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
287
|
-
await redis.rpush(listKey, lesson);
|
|
288
|
-
await redis.ltrim(listKey, -100, -1);
|
|
289
|
-
await redis.expire(listKey, 90 * 86400);
|
|
290
|
-
|
|
291
|
-
// Update best key for success/partial; for failure only update if no success exists
|
|
292
|
-
if (outcome === 'success' || outcome === 'partial') {
|
|
293
|
-
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
294
|
-
} else if (!existingRaw) {
|
|
295
|
-
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Track in decision log for session replay
|
|
299
|
-
try {
|
|
300
|
-
const dlKey = 'cachly:session:decision-log';
|
|
301
|
-
const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
|
|
302
|
-
await redis.rpush(dlKey, dlEntry);
|
|
303
|
-
await redis.ltrim(dlKey, -50, -1);
|
|
304
|
-
} catch { /* non-critical */ }
|
|
305
|
-
|
|
306
|
-
// ── Layer 1+2: CKG update (Causal Knowledge Graph + Belief Update Engine) ──
|
|
307
|
-
// BUE: Bayesian confidence, contradiction detection, second-degree propagation, decay
|
|
308
|
-
let beliefConflict: string | null = null;
|
|
309
|
-
try {
|
|
310
|
-
const conceptId = ckgSlug(topic);
|
|
311
|
-
const domain = topic.split(':')[0] ?? 'unknown';
|
|
312
|
-
const conceptType = domain; // fix, debug, deploy, infra, api, etc.
|
|
313
|
-
|
|
314
|
-
// Upsert concept node
|
|
315
|
-
await ckgUpsertNode(redis, conceptId, domain, conceptType);
|
|
316
|
-
|
|
317
|
-
// Tag co-occurrence edges
|
|
318
|
-
for (const tag of tags) {
|
|
319
|
-
const tagId = ckgSlug(`tag:${tag}`);
|
|
320
|
-
await ckgUpsertNode(redis, tagId, 'tag', 'tag');
|
|
321
|
-
await ckgUpdateEdge(redis, conceptId, 'co-occurs', tagId, outcome === 'success', outcome === 'partial');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// depends_on → requires edges (structural, always confidence 1.0 direction)
|
|
325
|
-
for (const dep of depends_on) {
|
|
326
|
-
const depId = ckgSlug(dep);
|
|
327
|
-
await ckgUpdateEdge(redis, conceptId, 'requires', depId, true);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// fixes edge: if category=fix and outcome=success, link to problem concept
|
|
331
|
-
if ((domain === 'fix' || domain === 'debug') && (outcome === 'success' || outcome === 'partial')) {
|
|
332
|
-
const problemText = what_failed || ctx || '';
|
|
333
|
-
const problemConcept = problemText ? extractProblemConcept(problemText) : null;
|
|
334
|
-
if (problemConcept) {
|
|
335
|
-
const problemId = ckgSlug(`problem:${problemConcept}`);
|
|
336
|
-
await ckgUpsertNode(redis, problemId, 'problem', 'problem');
|
|
337
|
-
await ckgUpdateEdge(redis, conceptId, 'fixes', problemId, outcome === 'success', outcome === 'partial');
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// causes edge: if outcome=failure, link topic concept to the problem context
|
|
342
|
-
if (outcome === 'failure' && (what_failed || what_worked)) {
|
|
343
|
-
const causeText = what_failed || what_worked;
|
|
344
|
-
const causeConcept = extractProblemConcept(causeText);
|
|
345
|
-
if (causeConcept) {
|
|
346
|
-
const causeId = ckgSlug(`cause:${causeConcept}`);
|
|
347
|
-
await ckgUpsertNode(redis, causeId, 'cause', 'cause');
|
|
348
|
-
await ckgUpdateEdge(redis, conceptId, 'causes', causeId, false);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ── BUE: Contradiction detection ──────────────────────────────────────
|
|
353
|
-
// If this topic previously had a confirmed 'fixes' edge (confidence > 0.7)
|
|
354
|
-
// and now outcome=failure → flag belief_conflict
|
|
355
|
-
if (outcome === 'failure') {
|
|
356
|
-
const existingEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
|
|
357
|
-
for (const ek of existingEdgeKeys) {
|
|
358
|
-
const er = await redis.get(ek);
|
|
359
|
-
if (!er) continue;
|
|
360
|
-
const existEdge = safeJsonParse<CKGEdge | null>(er, null);
|
|
361
|
-
if (!existEdge) continue;
|
|
362
|
-
if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
|
|
363
|
-
beliefConflict = `⚠️ **belief_conflict** on \`${topic}\`: previously confirmed fix (confidence ${existEdge.confidence.toFixed(2)}, n=${existEdge.trials}) now reports failure. Both beliefs retained as \`contested\`. Use \`ckg_inspect(concept="${conceptId}")\` to review.`;
|
|
364
|
-
// Store conflict marker
|
|
365
|
-
const conflictKey = `cachly:ckg:conflict:${conceptId}`;
|
|
366
|
-
await redis.set(conflictKey, JSON.stringify({ topic, detected_at: new Date().toISOString(), fix_confidence: existEdge.confidence, fix_trials: existEdge.trials, failure_outcome: outcome }), 'EX', 60 * 60 * 24 * 90);
|
|
367
|
-
// Auto-trigger MADC deliberation in background — never blocks learn_from_attempts
|
|
368
|
-
// Note: fire-and-forget; madc_deliberate is handled elsewhere in the switch
|
|
369
|
-
// (conflict marker stored above — no warm-up call needed)
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// ── BUE: Second-degree propagation ────────────────────────────────────
|
|
375
|
-
// When a 'fixes' edge gets stronger, boost co-occurring second-degree edges slightly
|
|
376
|
-
if (outcome === 'success' && (domain === 'fix' || domain === 'debug')) {
|
|
377
|
-
const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
|
|
378
|
-
for (const ek of fromEdgeKeys.slice(0, 10)) {
|
|
379
|
-
const er = await redis.get(ek);
|
|
380
|
-
if (!er) continue;
|
|
381
|
-
const e2 = safeJsonParse<CKGEdge | null>(er, null);
|
|
382
|
-
if (!e2) continue;
|
|
383
|
-
if (e2.edgeType !== 'fixes') continue;
|
|
384
|
-
// Boost second-degree: edges from e2.to get a small fractional success
|
|
385
|
-
const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
|
|
386
|
-
for (const sk of secondKeys.slice(0, 5)) {
|
|
387
|
-
const sr = await redis.get(sk);
|
|
388
|
-
if (!sr) continue;
|
|
389
|
-
const se = safeJsonParse<CKGEdge | null>(sr, null);
|
|
390
|
-
if (!se) continue;
|
|
391
|
-
if (se.edgeType !== 'co-occurs') continue;
|
|
392
|
-
// Add 0.1 fractional success (second-degree signal)
|
|
393
|
-
se.successes = (se.successes || 0) + 0.1;
|
|
394
|
-
se.trials = (se.trials || 0) + 0.1;
|
|
395
|
-
se.confidence = (se.successes + 1) / (se.trials + 2);
|
|
396
|
-
se.last_updated = new Date().toISOString();
|
|
397
|
-
await redis.set(sk, JSON.stringify(se));
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ── BUE: Stale edge decay ─────────────────────────────────────────────
|
|
403
|
-
// Edges older than 90 days with < 3 trials decay by 10% confidence.
|
|
404
|
-
// Only run probabilistically (1% of calls) to avoid per-call overhead.
|
|
405
|
-
if (Math.random() < 0.01) {
|
|
406
|
-
const decayCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
|
407
|
-
const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
|
|
408
|
-
for (const ek of fromEdgeKeys) {
|
|
409
|
-
const er = await redis.get(ek);
|
|
410
|
-
if (!er) continue;
|
|
411
|
-
const de = safeJsonParse<CKGEdge | null>(er, null);
|
|
412
|
-
if (!de) continue;
|
|
413
|
-
if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
|
|
414
|
-
de.confidence = de.confidence * 0.9;
|
|
415
|
-
de.last_updated = new Date().toISOString();
|
|
416
|
-
await redis.set(ek, JSON.stringify(de));
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
} catch { /* CKG updates are non-critical */ }
|
|
421
|
-
|
|
422
|
-
// ── Phase 3A: People + File nodes in knowledge graph ──────────────────────
|
|
423
|
-
// Builds the "who knows what" map automatically from author + file_paths.
|
|
424
|
-
// Person → authored → Concept edges power brain_who_knows queries.
|
|
425
|
-
if (author) {
|
|
426
|
-
try {
|
|
427
|
-
const domain = topic.split(':')[0] ?? 'unknown';
|
|
428
|
-
const personId = await ckgUpsertPersonNode(redis, author, domain);
|
|
429
|
-
const conceptId = ckgSlug(topic);
|
|
430
|
-
await ckgUpdateEdge(redis, personId, 'authored', conceptId, outcome === 'success', outcome === 'partial');
|
|
431
|
-
for (const fp of file_paths.slice(0, 8)) {
|
|
432
|
-
const fileId = await ckgUpsertFileNode(redis, fp);
|
|
433
|
-
await ckgUpdateEdge(redis, personId, 'touched', fileId, true);
|
|
434
|
-
}
|
|
435
|
-
} catch { /* non-critical */ }
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ── Ambient team propagation: if author is set + outcome is success,
|
|
439
|
-
// auto-store as a team lesson so knowledge is shared without a separate
|
|
440
|
-
// team_learn call. Tags gain 'team'; the lesson is attributed to the author.
|
|
441
|
-
// This makes team knowledge sharing the default, not the opt-in.
|
|
442
|
-
if (author && (outcome === 'success' || outcome === 'partial')) {
|
|
443
|
-
const teamLesson = {
|
|
444
|
-
topic, outcome, what_worked,
|
|
445
|
-
what_failed: what_failed ?? '',
|
|
446
|
-
severity,
|
|
447
|
-
author,
|
|
448
|
-
file_paths,
|
|
449
|
-
commands,
|
|
450
|
-
tags: [...new Set([...tags, 'team'])],
|
|
451
|
-
timestamp: new Date().toISOString(),
|
|
452
|
-
recall_count: 0,
|
|
453
|
-
version: 2,
|
|
454
|
-
auto_propagated: true,
|
|
455
|
-
};
|
|
456
|
-
const teamListKey = `cachly:lessons:${topic}`;
|
|
457
|
-
redis.rpush(teamListKey, JSON.stringify(teamLesson)).catch(() => {});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
461
|
-
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
462
|
-
const action = isUpdate ? 'updated' : 'stored';
|
|
463
|
-
return [
|
|
464
|
-
`${emoji} **Lesson ${action}:** \`${topic}\` (${outcome}) ${sevEmoji} ${severity}`,
|
|
465
|
-
beliefConflict ?? '',
|
|
466
|
-
``,
|
|
467
|
-
`**What worked:** ${what_worked}`,
|
|
468
|
-
what_failed ? `**What failed:** ${what_failed}` : '',
|
|
469
|
-
ctx ? `**Context:** ${ctx}` : '',
|
|
470
|
-
file_paths.length > 0 ? `**Files:** ${file_paths.map(f => `\`${f}\``).join(', ')}` : '',
|
|
471
|
-
commands.length > 0 ? `**Commands:** ${commands.map(c => `\`${c}\``).join(', ')}` : '',
|
|
472
|
-
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
473
|
-
``,
|
|
474
|
-
isUpdate
|
|
475
|
-
? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
|
|
476
|
-
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
477
|
-
(author && (outcome === 'success' || outcome === 'partial'))
|
|
478
|
-
? `👥 Auto-shared with team (by _${author}_) — visible in \`team_recall\``
|
|
479
|
-
: '',
|
|
480
|
-
depends_on.length > 0
|
|
481
|
-
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
482
|
-
: '',
|
|
483
|
-
...templateWarnings,
|
|
484
|
-
...iWasWrongWarning,
|
|
485
|
-
].filter(l => l !== '').join('\n');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
case 'recall_best_solution': {
|
|
489
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
490
|
-
const redis = await getConnection(instance_id);
|
|
491
|
-
|
|
492
|
-
// Try exact best-solution key first
|
|
493
|
-
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
494
|
-
if (best) {
|
|
495
|
-
const lesson = safeJsonParse(best, null as null | {
|
|
496
|
-
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
497
|
-
context?: string; ts: string; verified_at?: string; severity?: string;
|
|
498
|
-
file_paths?: string[]; commands?: string[]; tags?: string[];
|
|
499
|
-
recall_count?: number; audit_trail?: unknown[];
|
|
500
|
-
});
|
|
501
|
-
if (!lesson) return `⚠️ Lesson data for \`${topic}\` is corrupted. Re-store it with \`learn_from_attempts\`.`;
|
|
502
|
-
|
|
503
|
-
// ── Confidence decay check ───────────────────────────────────────────
|
|
504
|
-
const confidence = calculateConfidence(lesson);
|
|
505
|
-
const ref = lesson.verified_at ?? lesson.ts;
|
|
506
|
-
const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
|
|
507
|
-
const badge = confidenceBadge(confidence, ageDays);
|
|
508
|
-
|
|
509
|
-
// Recall resets verified_at (confidence clock restart)
|
|
510
|
-
const updatedLesson = {
|
|
511
|
-
...lesson,
|
|
512
|
-
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
513
|
-
verified_at: new Date().toISOString(),
|
|
514
|
-
confidence: 1.0,
|
|
515
|
-
};
|
|
516
|
-
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
517
|
-
|
|
518
|
-
// Track estimated time saved (30m minor · 60m major · 240m critical)
|
|
519
|
-
const savedMins = lesson.severity === 'critical' ? 240 : lesson.severity === 'major' ? 60 : 30;
|
|
520
|
-
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
521
|
-
|
|
522
|
-
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
523
|
-
const auditSummary = (lesson.audit_trail ?? []).length > 1
|
|
524
|
-
? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
|
|
525
|
-
: '';
|
|
526
|
-
|
|
527
|
-
// "Remember when..." — emotional header for lessons > 60 days old
|
|
528
|
-
const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
|
|
529
|
-
const rememberWhen = ageFromStoreDays > 60
|
|
530
|
-
? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
|
|
531
|
-
: '';
|
|
532
|
-
|
|
533
|
-
// "Never Google This Again" — suggest pinning after 3rd recall
|
|
534
|
-
const suggestPin = updatedLesson.recall_count === 3 && !(lesson as { pinned?: boolean }).pinned
|
|
535
|
-
? `📌 **You've looked this up 3 times.** Consider pinning it for instant access: add \`pinned: true\` via \`learn_from_attempts\` to always surface it first.`
|
|
536
|
-
: '';
|
|
537
|
-
|
|
538
|
-
// ── Trust signal (today-safe consensus layer) ───────────────────────
|
|
539
|
-
// A lesson recalled many times — or confirmed by multiple distinct
|
|
540
|
-
// authors — has proven its value. Surface that as social proof.
|
|
541
|
-
const lessonAuthors = (lesson as { authors?: string[]; author?: string }).authors
|
|
542
|
-
?? ((lesson as { author?: string }).author ? [(lesson as { author?: string }).author!] : []);
|
|
543
|
-
const distinctAuthors = [...new Set(lessonAuthors.filter(Boolean))];
|
|
544
|
-
const rc = updatedLesson.recall_count;
|
|
545
|
-
let trustBadge = '';
|
|
546
|
-
if (distinctAuthors.length >= 2 && rc >= 5) {
|
|
547
|
-
trustBadge = `🏆 **Battle-tested** — recalled ${rc}× · verified by ${distinctAuthors.length} developers. Trust this.`;
|
|
548
|
-
} else if (rc >= 10) {
|
|
549
|
-
trustBadge = `🏆 **Battle-tested** — recalled ${rc}×. This is one of your most-proven solutions.`;
|
|
550
|
-
} else if (distinctAuthors.length >= 2) {
|
|
551
|
-
trustBadge = `✅ **Team-verified** — confirmed by ${distinctAuthors.length} developers.`;
|
|
552
|
-
} else if (rc >= 5) {
|
|
553
|
-
trustBadge = `✅ **Proven** — recalled ${rc}× without contradiction.`;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return [
|
|
557
|
-
rememberWhen,
|
|
558
|
-
trustBadge,
|
|
559
|
-
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
560
|
-
``,
|
|
561
|
-
`**What worked:** ${lesson.what_worked}`,
|
|
562
|
-
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
563
|
-
lesson.context ? `**Context:** ${lesson.context}` : '',
|
|
564
|
-
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
|
|
565
|
-
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
|
|
566
|
-
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
|
|
567
|
-
auditSummary,
|
|
568
|
-
suggestPin,
|
|
569
|
-
].filter(l => l !== '').join('\n');
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Partial match: scan all lesson keys for topic substring
|
|
573
|
-
const allKeys: string[] = [];
|
|
574
|
-
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
575
|
-
await new Promise<void>((resolve, reject) => {
|
|
576
|
-
scanStream.on('data', (batch: string[]) => allKeys.push(...batch));
|
|
577
|
-
scanStream.on('end', resolve);
|
|
578
|
-
scanStream.on('error', reject);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
const matching = allKeys.filter(k => k.toLowerCase().includes(topic.toLowerCase()));
|
|
582
|
-
if (matching.length === 0) {
|
|
583
|
-
// Check attempt history as fallback
|
|
584
|
-
const histKey = `cachly:lessons:${topic}`;
|
|
585
|
-
const all = await redis.lrange(histKey, -3, -1);
|
|
586
|
-
if (all.length > 0) {
|
|
587
|
-
const parsed = all.map(e => safeJsonParse(e, null as null | { outcome: string; what_worked: string; ts: string })).filter(Boolean) as Array<{ outcome: string; what_worked: string; ts: string }>;
|
|
588
|
-
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
589
|
-
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
590
|
-
}
|
|
591
|
-
return `📭 No lessons found for \`${topic}\`. Use \`learn_from_attempts\` after solving it.`;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Return all partial matches
|
|
595
|
-
const results: string[] = [];
|
|
596
|
-
for (const k of matching.slice(0, 5)) {
|
|
597
|
-
const raw = await redis.get(k);
|
|
598
|
-
if (!raw) continue;
|
|
599
|
-
const lesson = safeJsonParse(raw, null as null | { topic: string; what_worked: string; context?: string; ts: string });
|
|
600
|
-
if (!lesson) continue;
|
|
601
|
-
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
602
|
-
}
|
|
603
|
-
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
case 'smart_recall': {
|
|
607
|
-
const {
|
|
608
|
-
instance_id,
|
|
609
|
-
query,
|
|
610
|
-
threshold = 0.78,
|
|
611
|
-
author: requester = '',
|
|
612
|
-
} = args as { instance_id: string; query: string; threshold?: number; author?: string };
|
|
613
|
-
|
|
614
|
-
const redis = await getConnection(instance_id);
|
|
615
|
-
|
|
616
|
-
// ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
|
|
617
|
-
const rawMatches = await keywordSearch(
|
|
618
|
-
redis,
|
|
619
|
-
['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'],
|
|
620
|
-
query,
|
|
621
|
-
10,
|
|
622
|
-
);
|
|
623
|
-
|
|
624
|
-
// ── Layer 1.5: Quality-aware rerank — proven success lessons outrank
|
|
625
|
-
// text-similar failed attempts (the moat; see src/rerank.ts + Cachly-Bench). ──
|
|
626
|
-
const kwMatches = rerankByQuality(rawMatches);
|
|
627
|
-
|
|
628
|
-
// Increment recall_count on matched lessons (fire-and-forget) + collect "Brain saved you here" signal.
|
|
629
|
-
type RecalledLesson = { topic: string; severity: string; recall_count: number; savedMins: number };
|
|
630
|
-
const savedHere: RecalledLesson[] = [];
|
|
631
|
-
const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
|
|
632
|
-
let crossAuthorThisCall = 0;
|
|
633
|
-
for (const m of lessonMatches.slice(0, 5)) {
|
|
634
|
-
const existing = await redis.get(m.key).catch(() => null);
|
|
635
|
-
if (existing) {
|
|
636
|
-
const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; author?: string; [k: string]: unknown });
|
|
637
|
-
if (lesson) {
|
|
638
|
-
const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
|
|
639
|
-
redis.set(m.key, JSON.stringify(updated)).catch(() => {});
|
|
640
|
-
const sev = lesson.severity as string;
|
|
641
|
-
const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
|
|
642
|
-
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
643
|
-
|
|
644
|
-
// Metric 3 (team-knowledge-reuse): a recall of a lesson authored by
|
|
645
|
-
// someone OTHER than the requester is cross-author reuse — the value
|
|
646
|
-
// only cachly delivers. Track total proven recalls + cross-author ones.
|
|
647
|
-
redis.incr(`cachly:stats:recalls_total:${instance_id}`).catch(() => {});
|
|
648
|
-
if (lesson.author && requester && lesson.author !== requester) {
|
|
649
|
-
crossAuthorThisCall++;
|
|
650
|
-
redis.incr(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => {});
|
|
651
|
-
redis.sadd(`cachly:stats:reuse_pairs:${instance_id}`, `${requester}<-${lesson.author}`).catch(() => {});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Surface banner for proven successes (recall_count >= 1 means it's been validated)
|
|
655
|
-
if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
|
|
656
|
-
savedHere.push({
|
|
657
|
-
topic: m.key.replace('cachly:lesson:best:', ''),
|
|
658
|
-
severity: sev ?? 'major',
|
|
659
|
-
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
660
|
-
savedMins,
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Metric 1 (time-to-first-recall): stamp the first time recall returned a
|
|
668
|
-
// proven lesson. SET NX so only the first successful recall wins.
|
|
669
|
-
if (savedHere.length > 0) {
|
|
670
|
-
redis.set(`cachly:stats:first_recall_at:${instance_id}`, new Date().toISOString(), 'EX', 365 * 86400, 'NX').catch(() => {});
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// ── Layer 2: Semantic search (parallel, optional) ────────────────────────
|
|
674
|
-
const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
|
|
675
|
-
type SemHit = { key: string; similarity: number; content: string };
|
|
676
|
-
const semHits: SemHit[] = [];
|
|
677
|
-
if (inst?.vector_token && hasEmbedProvider()) {
|
|
678
|
-
try {
|
|
679
|
-
const embedding = await computeEmbedding(query);
|
|
680
|
-
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst!.vector_token}`;
|
|
681
|
-
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
682
|
-
method: 'POST',
|
|
683
|
-
headers: { 'Content-Type': 'application/json' },
|
|
684
|
-
body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
|
|
685
|
-
});
|
|
686
|
-
if (searchRes.ok) {
|
|
687
|
-
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
688
|
-
for (const hit of results.filter(r => r.found && r.id)) {
|
|
689
|
-
const parts = hit.id!.replace('ctx:', '').split(':');
|
|
690
|
-
const category = parts[0];
|
|
691
|
-
const key = parts.slice(1).join(':');
|
|
692
|
-
const redisKey = `cachly:ctx:${category}:${key}`;
|
|
693
|
-
const content = await redis.get(redisKey).catch(() => null);
|
|
694
|
-
semHits.push({ key: redisKey, similarity: hit.similarity ?? 0, content: content ?? '(evicted)' });
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
} catch {
|
|
698
|
-
// Semantic search failed silently — keyword results are enough
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// ── Layer 3: Hybrid merge — one unified ranked list ──────────────────────
|
|
703
|
-
// BM25 quality scores (already reranked) are re-normalized to [0,1] for merging.
|
|
704
|
-
type HybridResult = {
|
|
705
|
-
key: string; content: string; hybridScore: number;
|
|
706
|
-
bm25Score?: number; semScore?: number; ckgScore?: number; matchedWords?: string[];
|
|
707
|
-
matchType: 'keyword' | 'semantic' | 'ckg' | 'hybrid'; subQuery?: string;
|
|
708
|
-
};
|
|
709
|
-
|
|
710
|
-
const bm25Scores = kwMatches.map(m => m.score);
|
|
711
|
-
const bm25Min = bm25Scores.length ? Math.min(...bm25Scores) : 0;
|
|
712
|
-
const bm25Range = bm25Scores.length ? (Math.max(...bm25Scores) - bm25Min) || 1 : 1;
|
|
713
|
-
const bm25Norm = (s: number) => (s - bm25Min) / bm25Range;
|
|
714
|
-
|
|
715
|
-
const hybridMap = new Map<string, HybridResult>();
|
|
716
|
-
for (const m of kwMatches) {
|
|
717
|
-
const n = bm25Norm(m.score);
|
|
718
|
-
hybridMap.set(m.key, {
|
|
719
|
-
key: m.key, content: m.content, bm25Score: n,
|
|
720
|
-
hybridScore: n * (semHits.length > 0 ? 0.7 : 1.0),
|
|
721
|
-
matchedWords: m.matchedWords, matchType: 'keyword', subQuery: m.subQuery,
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
for (const hit of semHits) {
|
|
725
|
-
const existing = hybridMap.get(hit.key);
|
|
726
|
-
if (existing) {
|
|
727
|
-
existing.semScore = hit.similarity;
|
|
728
|
-
existing.hybridScore = (existing.bm25Score ?? 0) * 0.6 + hit.similarity * 0.4;
|
|
729
|
-
existing.matchType = 'hybrid';
|
|
730
|
-
} else {
|
|
731
|
-
hybridMap.set(hit.key, {
|
|
732
|
-
key: hit.key, content: hit.content, semScore: hit.similarity,
|
|
733
|
-
hybridScore: hit.similarity * 0.5, matchType: 'semantic',
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
// ── Layer 3: CKG traversal — follow "fixes" edges to find lessons that solved
|
|
738
|
-
// structurally similar problems, even when vocabulary differs ───────────────
|
|
739
|
-
try {
|
|
740
|
-
const qTokens = tokenize(query).filter(t => t.length >= 4).slice(0, 4);
|
|
741
|
-
const candidateNodeIds = new Set<string>();
|
|
742
|
-
for (const token of qTokens) {
|
|
743
|
-
const nodeStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 20 });
|
|
744
|
-
await new Promise<void>((res, rej) => {
|
|
745
|
-
nodeStream.on('data', (batch: string[]) => {
|
|
746
|
-
for (const k of batch.slice(0, 5)) candidateNodeIds.add(k.replace('cachly:ckg:node:', ''));
|
|
747
|
-
});
|
|
748
|
-
nodeStream.on('end', res);
|
|
749
|
-
nodeStream.on('error', rej);
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const seenFroms = new Set<string>();
|
|
754
|
-
for (const nodeId of [...candidateNodeIds].slice(0, 12)) {
|
|
755
|
-
const inboundEdgeKeys = await redis.smembers(`cachly:ckg:idx:to:${nodeId}`);
|
|
756
|
-
for (const ek of inboundEdgeKeys.slice(0, 6)) {
|
|
757
|
-
const er = await redis.get(ek);
|
|
758
|
-
if (!er) continue;
|
|
759
|
-
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
760
|
-
if (!edge || edge.edgeType !== 'fixes' || edge.confidence < 0.35) continue;
|
|
761
|
-
if (seenFroms.has(edge.from)) continue;
|
|
762
|
-
seenFroms.add(edge.from);
|
|
763
|
-
|
|
764
|
-
const lessonKey = `cachly:lesson:best:${edge.from}`;
|
|
765
|
-
const existing = hybridMap.get(lessonKey);
|
|
766
|
-
if (existing) {
|
|
767
|
-
// Already found by BM25/semantic — boost with causal confirmation
|
|
768
|
-
existing.ckgScore = Math.max(existing.ckgScore ?? 0, edge.confidence);
|
|
769
|
-
existing.hybridScore = existing.hybridScore * 0.88 + edge.confidence * 0.12;
|
|
770
|
-
if (existing.matchType === 'keyword') existing.matchType = 'hybrid';
|
|
771
|
-
} else {
|
|
772
|
-
const content = await redis.get(lessonKey).catch(() => null);
|
|
773
|
-
if (!content) continue;
|
|
774
|
-
hybridMap.set(lessonKey, {
|
|
775
|
-
key: lessonKey, content,
|
|
776
|
-
ckgScore: edge.confidence,
|
|
777
|
-
hybridScore: edge.confidence * 0.30, // CKG-only: lower than text hits
|
|
778
|
-
matchType: 'ckg',
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
} catch { /* non-critical — keyword + semantic always available */ }
|
|
784
|
-
|
|
785
|
-
// Filter private lessons — they are only accessible via exact recall_best_solution
|
|
786
|
-
const hybridResults = [...hybridMap.values()]
|
|
787
|
-
.filter(r => {
|
|
788
|
-
if (!r.key.startsWith('cachly:lesson:best:')) return true;
|
|
789
|
-
const ld = safeJsonParse<{ visibility?: string }>(r.content, {});
|
|
790
|
-
return ld.visibility !== 'private';
|
|
791
|
-
})
|
|
792
|
-
.sort((a, b) => b.hybridScore - a.hybridScore);
|
|
793
|
-
|
|
794
|
-
// ── Build output ──────────────────────────────────────────────────────────
|
|
795
|
-
const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
|
|
796
|
-
|
|
797
|
-
// "Brain saved you here" banner — surfaces value inline for proven recalled lessons
|
|
798
|
-
const SORDER: Record<string, number> = { critical: 0, major: 1, minor: 2 };
|
|
799
|
-
const topLesson = savedHere.sort((a, b) => (SORDER[a.severity] ?? 1) - (SORDER[b.severity] ?? 1))[0];
|
|
800
|
-
if (topLesson) {
|
|
801
|
-
lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
|
|
802
|
-
}
|
|
803
|
-
if (crossAuthorThisCall > 0) {
|
|
804
|
-
lines.push(`> 👥 **Team knowledge reuse** — ${crossAuthorThisCall} of these lesson${crossAuthorThisCall !== 1 ? 's were' : ' was'} written by a teammate. This is the value only a shared brain delivers.\n`);
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Show sub-query info if multi-topic was detected
|
|
808
|
-
const subQueries = splitMultiQuery(query);
|
|
809
|
-
if (subQueries.length > 1) {
|
|
810
|
-
lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
if (hybridResults.length > 0) {
|
|
814
|
-
const hasCKG = hybridResults.some(r => r.ckgScore !== undefined);
|
|
815
|
-
const modeLabel = semHits.length > 0 && hasCKG
|
|
816
|
-
? `keyword + semantic + CKG hybrid`
|
|
817
|
-
: semHits.length > 0
|
|
818
|
-
? `keyword + semantic hybrid`
|
|
819
|
-
: hasCKG
|
|
820
|
-
? `keyword + CKG hybrid`
|
|
821
|
-
: `keyword`;
|
|
822
|
-
lines.push(`### 🔍 Results (${hybridResults.length} — ${modeLabel})\n`);
|
|
823
|
-
|
|
824
|
-
// Group by sub-query for multi-topic queries
|
|
825
|
-
if (subQueries.length > 1) {
|
|
826
|
-
const grouped = new Map<string, HybridResult[]>();
|
|
827
|
-
for (const r of hybridResults.slice(0, 12)) {
|
|
828
|
-
const sq = r.subQuery ?? query;
|
|
829
|
-
if (!grouped.has(sq)) grouped.set(sq, []);
|
|
830
|
-
grouped.get(sq)!.push(r);
|
|
831
|
-
}
|
|
832
|
-
for (const [sq, results] of grouped) {
|
|
833
|
-
lines.push(`**Topic: "${sq}"** (${results.length} results)\n`);
|
|
834
|
-
for (const r of results.slice(0, 4)) {
|
|
835
|
-
const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
|
|
836
|
-
const scorePart = r.matchType === 'ckg'
|
|
837
|
-
? `CKG: ${(r.ckgScore ?? 0).toFixed(2)}, 🕸️ causal graph`
|
|
838
|
-
: r.ckgScore !== undefined && r.bm25Score !== undefined
|
|
839
|
-
? `BM25: ${(r.bm25Score).toFixed(2)}, CKG: ${(r.ckgScore).toFixed(2)}, 🔀 hybrid`
|
|
840
|
-
: r.matchType === 'hybrid'
|
|
841
|
-
? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
|
|
842
|
-
: r.matchType === 'semantic'
|
|
843
|
-
? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
|
|
844
|
-
: `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
|
|
845
|
-
const preview = r.content.slice(0, 300).replace(/\n/g, ' ');
|
|
846
|
-
lines.push(` **${label}** _(${scorePart})_`);
|
|
847
|
-
lines.push(` > ${preview}${r.content.length > 300 ? '…' : ''}\n`);
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
const matched = [...grouped.keys()];
|
|
851
|
-
const unmatched = subQueries.filter(sq => !matched.includes(sq));
|
|
852
|
-
if (unmatched.length > 0) {
|
|
853
|
-
lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
|
|
854
|
-
}
|
|
855
|
-
} else {
|
|
856
|
-
for (const r of hybridResults.slice(0, 8)) {
|
|
857
|
-
const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
|
|
858
|
-
let authorBadge = '';
|
|
859
|
-
if (r.key.startsWith('cachly:lesson:best:')) {
|
|
860
|
-
const ld = safeJsonParse<{ author?: string }>(r.content, {});
|
|
861
|
-
if (ld.author) authorBadge = ` · 👤 ${ld.author}`;
|
|
862
|
-
}
|
|
863
|
-
const scorePart = r.matchType === 'hybrid'
|
|
864
|
-
? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
|
|
865
|
-
: r.matchType === 'semantic'
|
|
866
|
-
? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
|
|
867
|
-
: `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
|
|
868
|
-
const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
|
|
869
|
-
lines.push(`**${label}**${authorBadge} _(${scorePart})_`);
|
|
870
|
-
lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
} else {
|
|
874
|
-
lines.push(`⚠️ No matches found for: "${query}"`);
|
|
875
|
-
|
|
876
|
-
// Did-You-Mean: find nearest token in index vocab
|
|
877
|
-
const queryTokens = tokenize(query);
|
|
878
|
-
const suggestions: string[] = [];
|
|
879
|
-
if (_indexVocab.size > 0 && queryTokens.length > 0) {
|
|
880
|
-
for (const qt of queryTokens.slice(0, 3)) {
|
|
881
|
-
if (qt.length < 4) continue;
|
|
882
|
-
let bestDist = 3;
|
|
883
|
-
let bestTok = '';
|
|
884
|
-
for (const v of _indexVocab) {
|
|
885
|
-
if (v.length < 3 || Math.abs(v.length - qt.length) > 4) continue;
|
|
886
|
-
const d = levenshtein(qt, v);
|
|
887
|
-
if (d > 0 && d < bestDist) { bestDist = d; bestTok = v; }
|
|
888
|
-
}
|
|
889
|
-
if (bestTok) suggestions.push(`"${bestTok}" (instead of "${qt}")`);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
if (suggestions.length > 0) {
|
|
893
|
-
lines.push(`💡 **Did you mean:** ${suggestions.join(', ')}?`);
|
|
894
|
-
} else {
|
|
895
|
-
lines.push(`\n💡 Tips:`);
|
|
896
|
-
lines.push(` • Try different keywords`);
|
|
897
|
-
lines.push(` • Use \`list_remembered\` to see available context`);
|
|
898
|
-
lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
return lines.join('\n');
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// ── get_api_status ────────────────────────────────────────────────────────
|
|
906
|
-
|
|
907
|
-
case 'session_start': {
|
|
908
|
-
const { instance_id, focus = '', author = '', provider = '', workspace_path = '' } = args as { instance_id: string; focus?: string; author?: string; provider?: string; workspace_path?: string };
|
|
909
|
-
const redis = await getConnection(instance_id);
|
|
910
|
-
|
|
911
|
-
// 1. Scan all best-solution lessons
|
|
912
|
-
const lessonKeys: string[] = [];
|
|
913
|
-
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
914
|
-
await new Promise<void>((resolve, reject) => {
|
|
915
|
-
lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
|
|
916
|
-
lStream.on('end', resolve);
|
|
917
|
-
lStream.on('error', reject);
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
// 2. Fetch all lesson values for recency sorting + focus matching
|
|
921
|
-
type Lesson = {
|
|
922
|
-
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
923
|
-
ts: string; verified_at?: string; severity?: string; recall_count?: number;
|
|
924
|
-
tags?: string[]; confidence?: number; audit_trail?: unknown[];
|
|
925
|
-
};
|
|
926
|
-
const lessons: Lesson[] = [];
|
|
927
|
-
if (lessonKeys.length > 0) {
|
|
928
|
-
const raws = await redis.mget(...lessonKeys);
|
|
929
|
-
for (const raw of raws) {
|
|
930
|
-
const l = safeJsonParse<Lesson | null>(raw ?? null, null);
|
|
931
|
-
if (l) lessons.push(l);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
935
|
-
|
|
936
|
-
// 3. Count context entries (filter :meta keys)
|
|
937
|
-
let ctxCount = 0;
|
|
938
|
-
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
939
|
-
await new Promise<void>((resolve, reject) => {
|
|
940
|
-
ctxStream.on('data', (batch: string[]) => {
|
|
941
|
-
ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length;
|
|
942
|
-
});
|
|
943
|
-
ctxStream.on('end', resolve);
|
|
944
|
-
ctxStream.on('error', reject);
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
// 4. Last session
|
|
948
|
-
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
949
|
-
type LastSession = { summary: string; ts: string; files_changed?: string[]; duration_min?: number };
|
|
950
|
-
const lastSession = safeJsonParse<LastSession | null>(lastSessionRaw, null);
|
|
951
|
-
|
|
952
|
-
// 5. Focus filtering
|
|
953
|
-
const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
|
|
954
|
-
const focusLessons = focusTerms.length > 0
|
|
955
|
-
? lessons.filter(l =>
|
|
956
|
-
focusTerms.some(term =>
|
|
957
|
-
l.topic.toLowerCase().includes(term) ||
|
|
958
|
-
(l.tags ?? []).some((t: string) => t.toLowerCase().includes(term))
|
|
959
|
-
)
|
|
960
|
-
)
|
|
961
|
-
: [];
|
|
962
|
-
|
|
963
|
-
// 6. Streak tracking
|
|
964
|
-
let streakDays = 0;
|
|
965
|
-
let streakRecord = 0;
|
|
966
|
-
let streakMessage = '';
|
|
967
|
-
try {
|
|
968
|
-
const streakRaw = await redis.get('cachly:streak:current');
|
|
969
|
-
const streak = safeJsonParse<{ days: number; last_date: string; record: number } | null>(streakRaw, null);
|
|
970
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
971
|
-
if (streak) {
|
|
972
|
-
const lastDate = streak.last_date;
|
|
973
|
-
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
974
|
-
if (lastDate === today) {
|
|
975
|
-
// Already counted today
|
|
976
|
-
streakDays = streak.days;
|
|
977
|
-
streakRecord = streak.record;
|
|
978
|
-
} else if (lastDate === yesterday) {
|
|
979
|
-
// Continuing streak
|
|
980
|
-
streakDays = streak.days + 1;
|
|
981
|
-
streakRecord = Math.max(streakDays, streak.record);
|
|
982
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
|
|
983
|
-
} else {
|
|
984
|
-
// Streak broken
|
|
985
|
-
streakDays = 1;
|
|
986
|
-
streakRecord = streak.record;
|
|
987
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
|
|
988
|
-
}
|
|
989
|
-
} else {
|
|
990
|
-
// First session ever
|
|
991
|
-
streakDays = 1;
|
|
992
|
-
streakRecord = 1;
|
|
993
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
|
|
994
|
-
}
|
|
995
|
-
if (streakDays >= 7) streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
|
|
996
|
-
else if (streakDays > 1) streakMessage = `🔥 ${streakDays}-day streak`;
|
|
997
|
-
} catch { /* non-critical */ }
|
|
998
|
-
|
|
999
|
-
// 7. Save session start marker
|
|
1000
|
-
await redis.set('cachly:session:current', JSON.stringify({
|
|
1001
|
-
started: new Date().toISOString(),
|
|
1002
|
-
focus,
|
|
1003
|
-
provider,
|
|
1004
|
-
}), 'EX', 86400); // auto-expire after 24h if session_end never called
|
|
1005
|
-
|
|
1006
|
-
// 8. Time saved counter
|
|
1007
|
-
let timeSavedMins = 0;
|
|
1008
|
-
try {
|
|
1009
|
-
const raw = await redis.get(`cachly:stats:time_saved_mins:${instance_id}`);
|
|
1010
|
-
timeSavedMins = parseFloat(raw ?? '0');
|
|
1011
|
-
} catch { /* non-critical */ }
|
|
1012
|
-
|
|
1013
|
-
// ── Build briefing ──────────────────────────────────────────────────────
|
|
1014
|
-
const providerLabel = provider ? ` · ${provider}` : '';
|
|
1015
|
-
const lines: string[] = [`🧠 **Session Briefing**${providerLabel}`, ''];
|
|
1016
|
-
if (streakMessage) lines.push(streakMessage, '');
|
|
1017
|
-
|
|
1018
|
-
// Time saved (only show when meaningful — 30+ minutes)
|
|
1019
|
-
if (timeSavedMins >= 30) {
|
|
1020
|
-
const h = Math.floor(timeSavedMins / 60);
|
|
1021
|
-
const m = Math.round(timeSavedMins % 60);
|
|
1022
|
-
const timeStr = h > 0 ? `${h}h${m > 0 ? ` ${m}m` : ''}` : `${m}m`;
|
|
1023
|
-
lines.push(`⏱️ **Brain saved you ~${timeStr} total** (time not re-researching known fixes)`, '');
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// ── What's New (shown once per version update) ──────────────────────────
|
|
1027
|
-
try {
|
|
1028
|
-
const seenVersion = await redis.get('cachly:mcp:version:last_seen');
|
|
1029
|
-
if (seenVersion !== MCP_VERSION) {
|
|
1030
|
-
await redis.set('cachly:mcp:version:last_seen', MCP_VERSION, 'EX', 365 * 86400);
|
|
1031
|
-
const changelog = WHATS_NEW[MCP_VERSION];
|
|
1032
|
-
if (changelog) { lines.push(...changelog, ''); }
|
|
1033
|
-
}
|
|
1034
|
-
} catch { /* non-critical */ }
|
|
1035
|
-
|
|
1036
|
-
// ── First-time welcome (empty brain) ───────────────────────────────────
|
|
1037
|
-
const isFirstSession = !lastSession && lessons.length === 0 && ctxCount === 0;
|
|
1038
|
-
if (isFirstSession) {
|
|
1039
|
-
lines.push('🎉 **Welcome! Your AI Brain is live.**', '');
|
|
1040
|
-
if (workspace_path) {
|
|
1041
|
-
lines.push(`🚀 **Auto-bootstrapping from your git history...** (this takes a few seconds)`);
|
|
1042
|
-
lines.push(` Your brain will learn from your recent commits — no setup needed.`, '');
|
|
1043
|
-
} else {
|
|
1044
|
-
lines.push('It learns from your work automatically. After your first session it will look like this:', '');
|
|
1045
|
-
lines.push(' ✅ `api:auth` — Bearer token in header, not cookie; 401 on missing scope');
|
|
1046
|
-
lines.push(' ✅ `database:migrations` — always run migrations before deploy');
|
|
1047
|
-
lines.push(' ⚠️ `docker:build` — ARG changes bust all subsequent cache layers');
|
|
1048
|
-
lines.push('');
|
|
1049
|
-
lines.push('**Tip:** Pass `workspace_path` to `session_start` to auto-learn from git history instantly.');
|
|
1050
|
-
lines.push('');
|
|
1051
|
-
}
|
|
1052
|
-
lines.push('**Your brain grows automatically:**');
|
|
1053
|
-
lines.push(' • End each session → `session_end(summary="What I did")` — auto-learns from git commits');
|
|
1054
|
-
lines.push(' • After fixing bugs → `learn_from_attempts(topic="...", outcome="success", what_worked="...")`');
|
|
1055
|
-
lines.push('');
|
|
1056
|
-
lines.push('💡 Run `brain_doctor` for a health-check and personalised tips.');
|
|
1057
|
-
lines.push('');
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// ── Team-virality: first-team-briefing wow moment ────────────────────
|
|
1061
|
-
// When a user joins a team brain (has team lessons from colleagues but
|
|
1062
|
-
// has never been briefed on them), show a dedicated "Welcome to your
|
|
1063
|
-
// team's brain" section. Only fires once per user.
|
|
1064
|
-
if (author && !isFirstSession) {
|
|
1065
|
-
try {
|
|
1066
|
-
const briefingKey = `cachly:team:first_briefing:${author}`;
|
|
1067
|
-
const alreadyBriefed = await redis.get(briefingKey);
|
|
1068
|
-
if (!alreadyBriefed) {
|
|
1069
|
-
type LessonAny = typeof lessons[0] & { author?: string };
|
|
1070
|
-
const teamLessons = (lessons as LessonAny[]).filter(l => l.author && l.author !== author);
|
|
1071
|
-
if (teamLessons.length > 0) {
|
|
1072
|
-
// Mark briefed so this only fires once
|
|
1073
|
-
await redis.set(briefingKey, '1', 'EX', 365 * 86400);
|
|
1074
|
-
const byAuthor = new Map<string, LessonAny[]>();
|
|
1075
|
-
for (const l of teamLessons) {
|
|
1076
|
-
const a = l.author!;
|
|
1077
|
-
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
1078
|
-
byAuthor.get(a)!.push(l);
|
|
1079
|
-
}
|
|
1080
|
-
lines.push('');
|
|
1081
|
-
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
1082
|
-
lines.push('🤝 **Your team\'s AI brain has been briefing you.**');
|
|
1083
|
-
lines.push('');
|
|
1084
|
-
lines.push(`Your teammates have already solved problems you're about to hit:`);
|
|
1085
|
-
lines.push('');
|
|
1086
|
-
for (const [teamAuthor, tls] of byAuthor) {
|
|
1087
|
-
lines.push(` 👤 **${teamAuthor}** fixed ${tls.length} thing${tls.length > 1 ? 's' : ''}:`);
|
|
1088
|
-
for (const l of tls.slice(0, 2)) {
|
|
1089
|
-
const emoji = l.outcome === 'success' ? '✅' : '⚠️';
|
|
1090
|
-
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 90)}`);
|
|
1091
|
-
}
|
|
1092
|
-
if (tls.length > 2) lines.push(` … and ${tls.length - 2} more lessons`);
|
|
1093
|
-
}
|
|
1094
|
-
lines.push('');
|
|
1095
|
-
lines.push(`💡 Use \`team_learn\` after your next fix to pay it forward.`);
|
|
1096
|
-
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
1097
|
-
lines.push('');
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
} catch { /* team briefing errors must never break session_start */ }
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// Handoff from previous window (if any)
|
|
1104
|
-
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
1105
|
-
if (handoffRaw) {
|
|
1106
|
-
try {
|
|
1107
|
-
const handoff = JSON.parse(handoffRaw) as {
|
|
1108
|
-
ts: string; completed_tasks: string[]; remaining_tasks: string[];
|
|
1109
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
1110
|
-
instructions?: string; context_summary?: string; blocked_on?: string;
|
|
1111
|
-
};
|
|
1112
|
-
const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
|
|
1113
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
1114
|
-
|
|
1115
|
-
lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
|
|
1116
|
-
if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
|
|
1117
|
-
if (handoff.remaining_tasks.length > 0) {
|
|
1118
|
-
lines.push(` ⏳ **Remaining tasks:**`);
|
|
1119
|
-
for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
|
|
1120
|
-
}
|
|
1121
|
-
if (handoff.completed_tasks.length > 0) {
|
|
1122
|
-
lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
|
|
1123
|
-
}
|
|
1124
|
-
const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
|
|
1125
|
-
if (brokenFiles.length > 0) {
|
|
1126
|
-
lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
|
|
1127
|
-
}
|
|
1128
|
-
if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
|
|
1129
|
-
if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
|
|
1130
|
-
lines.push('');
|
|
1131
|
-
} catch { /* ignore corrupt handoff */ }
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// ── Last checkpoint (session_ping) — shown when no session_end found ────
|
|
1135
|
-
const checkpointRaw = await redis.get('cachly:session:checkpoint');
|
|
1136
|
-
if (checkpointRaw) {
|
|
1137
|
-
try {
|
|
1138
|
-
const cp = JSON.parse(checkpointRaw) as {
|
|
1139
|
-
ts: string; task: string; files_touched: string[]; next_step?: string; provider?: string;
|
|
1140
|
-
};
|
|
1141
|
-
// Only show checkpoint if it's more recent than last session_end
|
|
1142
|
-
const cpTime = new Date(cp.ts).getTime();
|
|
1143
|
-
const lastSessionTime = lastSession ? new Date(lastSession.ts).getTime() : 0;
|
|
1144
|
-
if (cpTime > lastSessionTime) {
|
|
1145
|
-
const ago = Math.round((Date.now() - cpTime) / 60000);
|
|
1146
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
1147
|
-
const providerStr = cp.provider ? ` [${cp.provider}]` : '';
|
|
1148
|
-
lines.push(`📌 **Last checkpoint**${providerStr} (${agoStr}): ${cp.task}`);
|
|
1149
|
-
if (cp.files_touched.length > 0) {
|
|
1150
|
-
lines.push(` Files: ${cp.files_touched.slice(0, 5).map(f => `\`${f}\``).join(', ')}`);
|
|
1151
|
-
}
|
|
1152
|
-
if (cp.next_step) lines.push(` 📍 Next step was: ${cp.next_step}`);
|
|
1153
|
-
if (!lastSession || cpTime - lastSessionTime > 300_000) {
|
|
1154
|
-
lines.push(` ⚠️ No \`session_end\` found — reconstructed from last checkpoint`);
|
|
1155
|
-
}
|
|
1156
|
-
lines.push('');
|
|
1157
|
-
}
|
|
1158
|
-
} catch { /* ignore */ }
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// ── Git reconstruction — when no session_end + workspace_path given ─────
|
|
1162
|
-
if (workspace_path && !lastSession) {
|
|
1163
|
-
try {
|
|
1164
|
-
const { execSync } = await import('node:child_process');
|
|
1165
|
-
const gitLog = execSync(
|
|
1166
|
-
`git -C "${workspace_path}" log --oneline --format="%h %s" -15 2>/dev/null`,
|
|
1167
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
1168
|
-
).trim();
|
|
1169
|
-
const gitDiff = execSync(
|
|
1170
|
-
`git -C "${workspace_path}" diff --stat HEAD~3 2>/dev/null || git -C "${workspace_path}" diff --stat 2>/dev/null`,
|
|
1171
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
1172
|
-
).trim();
|
|
1173
|
-
if (gitLog) {
|
|
1174
|
-
lines.push(`🔍 **Git reconstruction** (no session_end found — reconstructed from git):`);
|
|
1175
|
-
for (const l of gitLog.split('\n').slice(0, 8)) lines.push(` ${l}`);
|
|
1176
|
-
if (gitDiff) {
|
|
1177
|
-
const diffLines = gitDiff.split('\n').filter(l => l.includes('|') || l.includes('changed'));
|
|
1178
|
-
if (diffLines.length > 0) {
|
|
1179
|
-
lines.push(` **Recent changes:**`);
|
|
1180
|
-
for (const dl of diffLines.slice(0, 5)) lines.push(` ${dl.trim()}`);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
lines.push('');
|
|
1184
|
-
}
|
|
1185
|
-
} catch { /* git not available or no repo — silent */ }
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// Last session
|
|
1189
|
-
if (lastSession) {
|
|
1190
|
-
const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
1191
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
1192
|
-
lines.push(`📅 **Last session** (${agoStr}): ${lastSession.summary}`);
|
|
1193
|
-
if (lastSession.duration_min) lines.push(` Duration: ${lastSession.duration_min} min`);
|
|
1194
|
-
if ((lastSession.files_changed ?? []).length > 0) {
|
|
1195
|
-
lines.push(` Files: ${(lastSession.files_changed ?? []).slice(0, 5).map((f: string) => `\`${f}\``).join(', ')}`);
|
|
1196
|
-
}
|
|
1197
|
-
lines.push('');
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// Brain health
|
|
1201
|
-
lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
|
|
1202
|
-
|
|
1203
|
-
// ── 📌 Proven Laws — auto-crystallized lessons (quick win 3) ─────────────
|
|
1204
|
-
// A lesson that has proven itself (recalled ≥ CRYSTALLIZE_RECALLS times) or
|
|
1205
|
-
// was explicitly pinned becomes a "law" — surfaced at the top of every
|
|
1206
|
-
// briefing so the most battle-tested knowledge is never buried. This is
|
|
1207
|
-
// the today-safe version of swarm crystallization: enough confirmations
|
|
1208
|
-
// auto-promote a lesson without any manual step.
|
|
1209
|
-
{
|
|
1210
|
-
type LessonPin = typeof lessons[0] & { pinned?: boolean };
|
|
1211
|
-
const CRYSTALLIZE_RECALLS = 5;
|
|
1212
|
-
const laws = (lessons as LessonPin[])
|
|
1213
|
-
.filter(l => l.outcome === 'success' && (l.pinned === true || (l.recall_count ?? 0) >= CRYSTALLIZE_RECALLS))
|
|
1214
|
-
.sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0))
|
|
1215
|
-
.slice(0, 5);
|
|
1216
|
-
if (laws.length > 0) {
|
|
1217
|
-
lines.push(`📌 **Proven Laws** (auto-crystallized — your most-trusted knowledge):`);
|
|
1218
|
-
for (const l of laws) {
|
|
1219
|
-
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1220
|
-
const rc = l.recall_count ?? 0;
|
|
1221
|
-
const why = l.pinned === true ? 'pinned' : `recalled ${rc}×`;
|
|
1222
|
-
lines.push(` 🏆${sev} \`${l.topic}\` _(${why})_ — ${l.what_worked.slice(0, 100)}`);
|
|
1223
|
-
}
|
|
1224
|
-
lines.push('');
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
// ── Layer 7: MCM Domain Coverage Map ────────────────────────────────────
|
|
1229
|
-
if (lessons.length >= 3) {
|
|
1230
|
-
const domainMap = new Map<string, { total: number; success: number; critical: number }>();
|
|
1231
|
-
for (const l of lessons) {
|
|
1232
|
-
const dom = l.topic.split(':')[0] ?? 'other';
|
|
1233
|
-
if (!domainMap.has(dom)) domainMap.set(dom, { total: 0, success: 0, critical: 0 });
|
|
1234
|
-
const d = domainMap.get(dom)!;
|
|
1235
|
-
d.total++;
|
|
1236
|
-
if (l.outcome === 'success') d.success++;
|
|
1237
|
-
if (l.severity === 'critical') d.critical++;
|
|
1238
|
-
}
|
|
1239
|
-
const sorted = [...domainMap.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 6);
|
|
1240
|
-
const hasContestedDomains = sorted.some(([, d]) => d.success < d.total * 0.4 && d.total >= 2);
|
|
1241
|
-
if (sorted.length > 0) {
|
|
1242
|
-
lines.push(`🗺️ **Knowledge Coverage:**`);
|
|
1243
|
-
for (const [dom, d] of sorted) {
|
|
1244
|
-
const pct = Math.round((d.success / d.total) * 100);
|
|
1245
|
-
const filled = Math.round(pct / 10);
|
|
1246
|
-
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
1247
|
-
const flag = d.critical > 0 ? ' 🔴' : pct < 40 && d.total >= 2 ? ' ⚠️' : '';
|
|
1248
|
-
lines.push(` ${bar} ${dom.padEnd(18)} ${String(pct).padStart(3)}% (${d.success}/${d.total} confirmed)${flag}`);
|
|
1249
|
-
}
|
|
1250
|
-
if (hasContestedDomains) {
|
|
1251
|
-
lines.push(` ⚠️ _Some domains have contested beliefs — use \`ckg_inspect\` to review_`);
|
|
1252
|
-
}
|
|
1253
|
-
lines.push('');
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
// ── Layer 7 MCM: Active belief conflicts ─────────────────────────────────
|
|
1258
|
-
try {
|
|
1259
|
-
const conflictKeys: string[] = [];
|
|
1260
|
-
const cfStream = redis.scanStream({ match: 'cachly:ckg:conflict:*', count: 50 });
|
|
1261
|
-
await new Promise<void>((res, rej) => { cfStream.on('data', (b: string[]) => conflictKeys.push(...b)); cfStream.on('end', res); cfStream.on('error', rej); });
|
|
1262
|
-
if (conflictKeys.length > 0) {
|
|
1263
|
-
lines.push(`⚡ **Active belief conflicts (${conflictKeys.length}):**`);
|
|
1264
|
-
for (const ck of conflictKeys.slice(0, 3)) {
|
|
1265
|
-
const cr = await redis.get(ck);
|
|
1266
|
-
if (!cr) continue;
|
|
1267
|
-
const cf = safeJsonParse<{ topic: string; fix_confidence: number; fix_trials: number } | null>(cr, null);
|
|
1268
|
-
if (!cf) continue;
|
|
1269
|
-
lines.push(` ⚠️ \`${cf.topic}\` — previously confirmed fix (${(cf.fix_confidence * 100).toFixed(0)}%, n=${cf.fix_trials}) now contradicted. Use \`ckg_inspect(concept="${ckgSlug(cf.topic)}")\``);
|
|
1270
|
-
}
|
|
1271
|
-
lines.push('');
|
|
1272
|
-
}
|
|
1273
|
-
} catch { /* non-critical */ }
|
|
1274
|
-
|
|
1275
|
-
// ── Layer 7 MCM: Blind Spot Detection ────────────────────────────────────
|
|
1276
|
-
// If the focus mentions a domain that has no CKG node → surface blind spot
|
|
1277
|
-
if (focus && focus.length > 3) {
|
|
1278
|
-
try {
|
|
1279
|
-
const focusTokens = focus.toLowerCase().replace(/[^a-z0-9\s:_-]/g, ' ').split(/\s+/).filter(t => t.length > 3);
|
|
1280
|
-
const blindSpots: string[] = [];
|
|
1281
|
-
for (const token of focusTokens.slice(0, 6)) {
|
|
1282
|
-
const nodeExists = await redis.exists(`cachly:ckg:node:${token}`);
|
|
1283
|
-
if (!nodeExists) {
|
|
1284
|
-
// Check if any node starts with this token (prefix match)
|
|
1285
|
-
const prefixKeys: string[] = [];
|
|
1286
|
-
const psStream = redis.scanStream({ match: `cachly:ckg:node:${token}*`, count: 10 });
|
|
1287
|
-
await new Promise<void>((res, rej) => { psStream.on('data', (b: string[]) => prefixKeys.push(...b)); psStream.on('end', res); psStream.on('error', rej); });
|
|
1288
|
-
if (prefixKeys.length === 0) blindSpots.push(token);
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
if (blindSpots.length > 0) {
|
|
1292
|
-
lines.push(`🔭 **Blind spots detected for this focus:**`);
|
|
1293
|
-
for (const bs of blindSpots.slice(0, 3)) {
|
|
1294
|
-
lines.push(` ⬜ \`${bs}\` — no CKG knowledge. Suggestions:`);
|
|
1295
|
-
lines.push(` • \`brain_from_git(instance_id="...", concept="${bs}")\` — bootstrap from commits`);
|
|
1296
|
-
lines.push(` • \`fedbrain_search(query="${bs}")\` — search global commons`);
|
|
1297
|
-
}
|
|
1298
|
-
lines.push('');
|
|
1299
|
-
}
|
|
1300
|
-
} catch { /* non-critical */ }
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
if (focusLessons.length > 0) {
|
|
1304
|
-
lines.push(`🎯 **Relevant for "${focus}":**`);
|
|
1305
|
-
for (const l of focusLessons.slice(0, 4)) {
|
|
1306
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
1307
|
-
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1308
|
-
lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
|
|
1309
|
-
}
|
|
1310
|
-
lines.push('');
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
// Top lessons — sorted by recall_count desc (most-used = most proven value).
|
|
1314
|
-
// If focus is set, focus-matched lessons already shown above; show remaining here.
|
|
1315
|
-
if (lessons.length > 0) {
|
|
1316
|
-
const byRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0));
|
|
1317
|
-
const topRecalled = byRecall.filter(l => (l.recall_count ?? 0) > 0).slice(0, 3);
|
|
1318
|
-
const remaining = (focusLessons.length > 0 ? lessons.filter(l => !focusLessons.includes(l)) : lessons)
|
|
1319
|
-
.filter(l => !topRecalled.includes(l))
|
|
1320
|
-
.slice(0, focusLessons.length > 0 ? 3 : 4);
|
|
1321
|
-
|
|
1322
|
-
if (topRecalled.length > 0) {
|
|
1323
|
-
lines.push(`🏆 **Most valuable** (recalled ${topRecalled[0]!.recall_count}× before):`);
|
|
1324
|
-
for (const l of topRecalled) {
|
|
1325
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
1326
|
-
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1327
|
-
const rc = l.recall_count ?? 0;
|
|
1328
|
-
const la = (l as { authors?: string[]; author?: string }).authors
|
|
1329
|
-
?? ((l as { author?: string }).author ? [(l as { author?: string }).author!] : []);
|
|
1330
|
-
const nAuthors = new Set(la.filter(Boolean)).size;
|
|
1331
|
-
const trust = (nAuthors >= 2 && rc >= 5) || rc >= 10 ? ' 🏆'
|
|
1332
|
-
: nAuthors >= 2 || rc >= 5 ? ' ✅' : '';
|
|
1333
|
-
const rct = rc > 1 ? ` _(${rc}× recalled)_` : '';
|
|
1334
|
-
lines.push(` ${emoji}${sev}${trust} \`${l.topic}\`${rct} — ${l.what_worked.slice(0, 100)}`);
|
|
1335
|
-
}
|
|
1336
|
-
lines.push('');
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
if (remaining.length > 0) {
|
|
1340
|
-
const header = topRecalled.length > 0 ? `🕐 **Recent:**` : `🕐 **Recent lessons:**`;
|
|
1341
|
-
lines.push(header);
|
|
1342
|
-
for (const l of remaining) {
|
|
1343
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
1344
|
-
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1345
|
-
lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
|
|
1346
|
-
}
|
|
1347
|
-
lines.push('');
|
|
1348
|
-
}
|
|
1349
|
-
} else {
|
|
1350
|
-
lines.push('📭 No lessons yet. Use `learn_from_attempts` after solving tasks.', '');
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// Team invite prompt — fires once after 10th lesson if no team use yet
|
|
1354
|
-
if (lessons.length >= 10) {
|
|
1355
|
-
const hasTeamUse = lessons.some(l => (l as typeof l & { author?: string }).author);
|
|
1356
|
-
if (!hasTeamUse) {
|
|
1357
|
-
lines.push(`🤝 **Your Brain has ${lessons.length} lessons — your team could benefit instantly.**`);
|
|
1358
|
-
lines.push(` Share: **cachly.dev/team** · or run \`team_learn\` after the next fix.`, '');
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// Open failures (lessons whose best-key has outcome != success)
|
|
1363
|
-
const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
|
|
1364
|
-
if (openFailures.length > 0) {
|
|
1365
|
-
lines.push(`⚠️ **Unresolved** (${openFailures.length} topic${openFailures.length > 1 ? 's' : ''} with no success yet):`);
|
|
1366
|
-
for (const l of openFailures.slice(0, 3)) {
|
|
1367
|
-
lines.push(` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`);
|
|
1368
|
-
}
|
|
1369
|
-
lines.push('');
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// ── Stale / low-confidence lessons (confidence decay) ─────────────────
|
|
1373
|
-
const staleSuccessLessons = lessons.filter(l => {
|
|
1374
|
-
if (l.outcome !== 'success' && l.outcome !== 'partial') return false;
|
|
1375
|
-
return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
|
|
1376
|
-
});
|
|
1377
|
-
if (staleSuccessLessons.length > 0) {
|
|
1378
|
-
lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
|
|
1379
|
-
for (const l of staleSuccessLessons.slice(0, 4)) {
|
|
1380
|
-
const conf = calculateConfidence(l);
|
|
1381
|
-
const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
|
|
1382
|
-
const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
|
|
1383
|
-
lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
|
|
1384
|
-
}
|
|
1385
|
-
lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
|
|
1386
|
-
lines.push('');
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
// ── Session Replay: show last session's decision log ──────────────────
|
|
1390
|
-
const lastSessionAny = lastSession as unknown as { decision_log?: Array<{ topic: string; outcome: string; what_worked: string }> } | null;
|
|
1391
|
-
if (lastSessionAny?.decision_log?.length) {
|
|
1392
|
-
const dl = lastSessionAny.decision_log;
|
|
1393
|
-
const successes = dl.filter(d => d.outcome === 'success');
|
|
1394
|
-
const failures = dl.filter(d => d.outcome === 'failure');
|
|
1395
|
-
lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
|
|
1396
|
-
if (successes.length > 0) lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
1397
|
-
if (failures.length > 0) lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
1398
|
-
lines.push('');
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
// ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
|
|
1402
|
-
// Fires BEFORE work starts. Uses explicit focus when given; otherwise
|
|
1403
|
-
// derives the likely work area from the last session's changed files +
|
|
1404
|
-
// summary (you usually keep working where you left off). This makes the
|
|
1405
|
-
// warning fire even when the caller forgets to pass `focus`.
|
|
1406
|
-
{
|
|
1407
|
-
type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
|
|
1408
|
-
|
|
1409
|
-
// Common path noise / extensions to drop when deriving terms from files.
|
|
1410
|
-
const PATH_NOISE = new Set([
|
|
1411
|
-
'src', 'lib', 'test', 'tests', 'dist', 'index', 'main', 'app',
|
|
1412
|
-
'internal', 'pkg', 'cmd', 'node_modules', 'components', 'utils',
|
|
1413
|
-
'ts', 'tsx', 'js', 'jsx', 'go', 'py', 'rs', 'java', 'json', 'yaml', 'yml',
|
|
1414
|
-
]);
|
|
1415
|
-
const deriveTermsFromFiles = (files: string[]): string[] => {
|
|
1416
|
-
const terms = new Set<string>();
|
|
1417
|
-
for (const f of files) {
|
|
1418
|
-
for (const seg of f.toLowerCase().split(/[/\\._-]/)) {
|
|
1419
|
-
if (seg.length > 3 && !PATH_NOISE.has(seg)) terms.add(seg);
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
return [...terms];
|
|
1423
|
-
};
|
|
1424
|
-
|
|
1425
|
-
let warnTerms = focusTerms;
|
|
1426
|
-
let warnLabel = focus;
|
|
1427
|
-
let derived = false;
|
|
1428
|
-
if (warnTerms.length === 0 && lastSession) {
|
|
1429
|
-
const fromFiles = deriveTermsFromFiles(lastSession.files_changed ?? []);
|
|
1430
|
-
const fromSummary = (lastSession.summary ?? '').toLowerCase()
|
|
1431
|
-
.replace(/[^a-z0-9\s:_-]/g, ' ').split(/\s+/).filter(t => t.length > 3 && !PATH_NOISE.has(t));
|
|
1432
|
-
warnTerms = [...new Set([...fromFiles, ...fromSummary])];
|
|
1433
|
-
warnLabel = 'where you left off last session';
|
|
1434
|
-
derived = true;
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
if (warnTerms.length > 0) {
|
|
1438
|
-
const dangerLessons = (lessons as LessonAny[]).filter(l => {
|
|
1439
|
-
if (l.outcome === 'success') return false;
|
|
1440
|
-
const topicCategory = l.topic.split(':')[0];
|
|
1441
|
-
return warnTerms.some(term =>
|
|
1442
|
-
l.topic.toLowerCase().includes(term) ||
|
|
1443
|
-
topicCategory === term ||
|
|
1444
|
-
(l.tags ?? []).some((t: string) => t.toLowerCase() === term),
|
|
1445
|
-
);
|
|
1446
|
-
});
|
|
1447
|
-
if (dangerLessons.length >= 1) {
|
|
1448
|
-
const headline = derived
|
|
1449
|
-
? ` You're likely to continue **${warnLabel}** — ${dangerLessons.length} known pitfall${dangerLessons.length > 1 ? 's' : ''} there:`
|
|
1450
|
-
: ` Known pitfalls for **"${warnLabel}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`;
|
|
1451
|
-
const warning = [
|
|
1452
|
-
`🚨 **PRE-WARNING** — Read this BEFORE starting:`,
|
|
1453
|
-
headline,
|
|
1454
|
-
...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
|
|
1455
|
-
'',
|
|
1456
|
-
];
|
|
1457
|
-
lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// ── 👥 Team Telepathy — what teammates learned this week ─────────────────
|
|
1463
|
-
if (author) {
|
|
1464
|
-
type LessonAny = typeof lessons[0] & { author?: string };
|
|
1465
|
-
const oneWeekAgo = Date.now() - 7 * 86_400_000;
|
|
1466
|
-
const teamLessons = (lessons as LessonAny[]).filter(l =>
|
|
1467
|
-
l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo,
|
|
1468
|
-
);
|
|
1469
|
-
if (teamLessons.length > 0) {
|
|
1470
|
-
// Group by author
|
|
1471
|
-
const byAuthor = new Map<string, LessonAny[]>();
|
|
1472
|
-
for (const l of teamLessons) {
|
|
1473
|
-
const a = l.author!;
|
|
1474
|
-
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
1475
|
-
byAuthor.get(a)!.push(l);
|
|
1476
|
-
}
|
|
1477
|
-
lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
|
|
1478
|
-
for (const [teamAuthor, tls] of byAuthor) {
|
|
1479
|
-
lines.push(` 👤 **${teamAuthor}**:`);
|
|
1480
|
-
for (const l of tls.slice(0, 3)) {
|
|
1481
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
1482
|
-
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
|
|
1483
|
-
}
|
|
1484
|
-
if (tls.length > 3) lines.push(` … and ${tls.length - 3} more`);
|
|
1485
|
-
}
|
|
1486
|
-
lines.push('');
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
|
|
1491
|
-
try {
|
|
1492
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
1493
|
-
if (crystalRaw) {
|
|
1494
|
-
type CrystalData = { label: string; ts: string; session_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
|
|
1495
|
-
const crystal = safeJsonParse<CrystalData | null>(crystalRaw, null);
|
|
1496
|
-
if (crystal) {
|
|
1497
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
1498
|
-
if (crystalAge <= 90) {
|
|
1499
|
-
lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
|
|
1500
|
-
for (const p of crystal.top_patterns.slice(0, 3)) {
|
|
1501
|
-
lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
|
|
1502
|
-
}
|
|
1503
|
-
lines.push('');
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
} catch { /* non-critical */ }
|
|
1508
|
-
|
|
1509
|
-
// ── 🗺️ Roadmap — open items at session start ────────────────────────────
|
|
1510
|
-
try {
|
|
1511
|
-
const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
1512
|
-
if (roadmapAll && Object.keys(roadmapAll).length > 0) {
|
|
1513
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
1514
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
1515
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
1516
|
-
const allItems = Object.values(roadmapAll).flatMap(v => {
|
|
1517
|
-
const item = safeJsonParse<Record<string, unknown> | null>(v as string, null);
|
|
1518
|
-
return item ? [item] : [];
|
|
1519
|
-
});
|
|
1520
|
-
const openItems = allItems
|
|
1521
|
-
.filter(i => openStatuses.has(i.status as string))
|
|
1522
|
-
.sort((a, b) => {
|
|
1523
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
1524
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
1525
|
-
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
1526
|
-
});
|
|
1527
|
-
const doneCount = allItems.filter(i => i.status === 'done').length;
|
|
1528
|
-
if (openItems.length > 0) {
|
|
1529
|
-
lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
|
|
1530
|
-
for (const it of openItems.slice(0, 5)) {
|
|
1531
|
-
const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
|
|
1532
|
-
lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
|
|
1533
|
-
}
|
|
1534
|
-
if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
|
|
1535
|
-
lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
|
|
1536
|
-
lines.push('');
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
} catch { /* non-critical */ }
|
|
1540
|
-
|
|
1541
|
-
// ── 🔮 PPE: Predictive Pre-fetch — CKG-powered risk detection ────────────
|
|
1542
|
-
// Layer 4: Before starting any work, scan the CKG for nodes matching focus
|
|
1543
|
-
// tokens and traverse causal edges to surface predicted failure points inline.
|
|
1544
|
-
// This is the PPE "pre-fetch" that was previously only available via brain_predict.
|
|
1545
|
-
if (focus && focus.length > 3) {
|
|
1546
|
-
try {
|
|
1547
|
-
const ppeFocusTokens = focus.toLowerCase().replace(/[^a-z0-9\s\-_:]/g, ' ').split(/\s+/).filter(t => t.length > 2).slice(0, 5);
|
|
1548
|
-
type PPEPrediction = { concept: string; edgeType: string; target: string; confidence: number; lesson?: { what_worked?: string; topic: string } };
|
|
1549
|
-
const ppePredictions: PPEPrediction[] = [];
|
|
1550
|
-
|
|
1551
|
-
for (const token of ppeFocusTokens) {
|
|
1552
|
-
const nodeKeys: string[] = [];
|
|
1553
|
-
const nStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 20 });
|
|
1554
|
-
await new Promise<void>((res, rej) => { nStream.on('data', (b: string[]) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
|
|
1555
|
-
|
|
1556
|
-
for (const nk of nodeKeys.slice(0, 3)) {
|
|
1557
|
-
const nodeRaw = await redis.get(nk);
|
|
1558
|
-
if (!nodeRaw) continue;
|
|
1559
|
-
const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
|
|
1560
|
-
if (!node) continue;
|
|
1561
|
-
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
1562
|
-
for (const ek of edgeKeys.slice(0, 15)) {
|
|
1563
|
-
const edgeRaw = await redis.get(ek);
|
|
1564
|
-
if (!edgeRaw) continue;
|
|
1565
|
-
const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
|
|
1566
|
-
if (!edge) continue;
|
|
1567
|
-
if (edge.edgeType !== 'causes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'fixes') continue;
|
|
1568
|
-
if (edge.confidence < 0.35) continue;
|
|
1569
|
-
const lessonKey = edge.edgeType === 'fixes' ? `cachly:lesson:best:${edge.from}` : `cachly:lesson:best:${edge.to}`;
|
|
1570
|
-
const lessonRaw = await redis.get(lessonKey);
|
|
1571
|
-
const lesson = safeJsonParse<{ what_worked?: string; topic: string } | null>(lessonRaw, null);
|
|
1572
|
-
ppePredictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson: lesson ?? undefined });
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
const ppeSeen = new Set<string>();
|
|
1578
|
-
const ppeUniq = ppePredictions
|
|
1579
|
-
.filter(p => { const k = `${p.concept}:${p.target}`; if (ppeSeen.has(k)) return false; ppeSeen.add(k); return true; })
|
|
1580
|
-
.sort((a, b) => b.confidence - a.confidence)
|
|
1581
|
-
.slice(0, 4);
|
|
1582
|
-
|
|
1583
|
-
if (ppeUniq.length > 0) {
|
|
1584
|
-
lines.push(`🔮 **Predicted risks for "${focus}"** (PPE pre-fetch):`);
|
|
1585
|
-
for (const p of ppeUniq) {
|
|
1586
|
-
const confPct = Math.round(p.confidence * 100);
|
|
1587
|
-
const icon = p.edgeType === 'causes' ? '⚡' : p.edgeType === 'fixes' ? '🔧' : '🔄';
|
|
1588
|
-
lines.push(` ${icon} **${confPct}%** \`${p.concept}\` ${p.edgeType} \`${p.target}\``);
|
|
1589
|
-
if (p.lesson?.what_worked) lines.push(` ✅ ${(p.lesson.what_worked as string).slice(0, 110)}`);
|
|
1590
|
-
}
|
|
1591
|
-
lines.push(` _Full analysis: \`brain_predict(context="${focus}")\`_`);
|
|
1592
|
-
lines.push('');
|
|
1593
|
-
}
|
|
1594
|
-
} catch { /* non-critical — never block session start */ }
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// ── 📐 MCM Confidence Calibration — 30-day accuracy pass ─────────────────
|
|
1598
|
-
// Layer 7: Periodically check if high-confidence lessons are actually reliable.
|
|
1599
|
-
// If last calibration was >30 days ago (or never), run a quick pass.
|
|
1600
|
-
// Measures: of recalled lessons with confidence > 0.85, what % had outcome=success?
|
|
1601
|
-
try {
|
|
1602
|
-
const calRaw = await redis.get('cachly:mcm:calibration:last');
|
|
1603
|
-
const lastCalMs = safeJsonParse<{ ts?: number } | null>(calRaw, null)?.ts ?? 0;
|
|
1604
|
-
const daysSinceCal = (Date.now() - lastCalMs) / 86_400_000;
|
|
1605
|
-
if (daysSinceCal >= 30 && lessons.length >= 5) {
|
|
1606
|
-
// Quick calibration pass on recalled lessons (recall_count > 0, outcome=success)
|
|
1607
|
-
const recalledSuccess = lessons.filter(l => l.outcome === 'success' && (l.recall_count ?? 0) > 0);
|
|
1608
|
-
const recalledAll = lessons.filter(l => (l.recall_count ?? 0) > 0);
|
|
1609
|
-
if (recalledAll.length >= 3) {
|
|
1610
|
-
const precision = recalledAll.length > 0 ? recalledSuccess.length / recalledAll.length : 1;
|
|
1611
|
-
const lowPrecisionDomains: string[] = [];
|
|
1612
|
-
// Per-domain breakdown
|
|
1613
|
-
const domCalMap = new Map<string, { success: number; total: number }>();
|
|
1614
|
-
for (const l of recalledAll) {
|
|
1615
|
-
const dom = l.topic.split(':')[0] ?? 'other';
|
|
1616
|
-
if (!domCalMap.has(dom)) domCalMap.set(dom, { success: 0, total: 0 });
|
|
1617
|
-
const d = domCalMap.get(dom)!;
|
|
1618
|
-
d.total++;
|
|
1619
|
-
if (l.outcome === 'success') d.success++;
|
|
1620
|
-
}
|
|
1621
|
-
for (const [dom, d] of domCalMap) {
|
|
1622
|
-
if (d.total >= 2 && d.success / d.total < 0.6) lowPrecisionDomains.push(dom);
|
|
1623
|
-
}
|
|
1624
|
-
// Save calibration result
|
|
1625
|
-
await redis.set('cachly:mcm:calibration:last', JSON.stringify({
|
|
1626
|
-
ts: Date.now(),
|
|
1627
|
-
precision: precision,
|
|
1628
|
-
recalled: recalledAll.length,
|
|
1629
|
-
low_precision_domains: lowPrecisionDomains,
|
|
1630
|
-
}));
|
|
1631
|
-
if (lowPrecisionDomains.length > 0 || precision < 0.7) {
|
|
1632
|
-
lines.push(`📐 **MCM Calibration** (30-day pass — ${recalledAll.length} recalled lessons):`);
|
|
1633
|
-
lines.push(` Overall precision: **${(precision * 100).toFixed(0)}%** recalled lessons actually worked`);
|
|
1634
|
-
if (lowPrecisionDomains.length > 0) {
|
|
1635
|
-
lines.push(` ⚠️ Low-precision domains: ${lowPrecisionDomains.map(d => `\`${d}\``).join(', ')} — consider revisiting these lessons`);
|
|
1636
|
-
}
|
|
1637
|
-
lines.push(` 💡 Use \`learn_from_attempts\` to update stale lessons and improve accuracy.`);
|
|
1638
|
-
lines.push('');
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
// Even if no issues, stamp the date so we don't re-check for 30d
|
|
1642
|
-
await redis.set('cachly:mcm:calibration:last', JSON.stringify({ ts: Date.now(), precision: 1, recalled: 0 }), 'EX', 35 * 86400);
|
|
1643
|
-
}
|
|
1644
|
-
} catch { /* non-critical */ }
|
|
1645
|
-
|
|
1646
|
-
// ── 🌍 Knowledge Commons — community stats banner ───────────────────────
|
|
1647
|
-
try {
|
|
1648
|
-
const commonsStats = await apiFetch<{
|
|
1649
|
-
total_lessons: number;
|
|
1650
|
-
total_confirms: number;
|
|
1651
|
-
added_last_7_days: number;
|
|
1652
|
-
}>('/api/v1/syndication/stats');
|
|
1653
|
-
if (commonsStats.total_lessons > 0) {
|
|
1654
|
-
lines.push(
|
|
1655
|
-
`🌍 **Commons:** ${commonsStats.total_lessons.toLocaleString()} lessons · ` +
|
|
1656
|
-
`${commonsStats.total_confirms.toLocaleString()} confirms · ` +
|
|
1657
|
-
`+${commonsStats.added_last_7_days} this week`,
|
|
1658
|
-
);
|
|
1659
|
-
lines.push('');
|
|
1660
|
-
}
|
|
1661
|
-
} catch { /* non-critical — never block session start */ }
|
|
1662
|
-
|
|
1663
|
-
// ── Brain Doctor hint (Punkt 6) — surface when brain needs attention ───
|
|
1664
|
-
const hasOpenFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length > 0;
|
|
1665
|
-
const hasStaleLessons = lessons.some(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
1666
|
-
if (lessons.length === 0 || hasOpenFailures || hasStaleLessons) {
|
|
1667
|
-
const reasons: string[] = [];
|
|
1668
|
-
if (lessons.length === 0) reasons.push('brain is empty');
|
|
1669
|
-
if (hasOpenFailures) reasons.push('open failures');
|
|
1670
|
-
if (hasStaleLessons) reasons.push('stale lessons');
|
|
1671
|
-
lines.push(`🩺 _Run \`brain_doctor\` to fix: ${reasons.join(', ')}._`);
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
return lines.join('\n');
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// ── session_end ───────────────────────────────────────────────────────────
|
|
1678
|
-
case 'session_end': {
|
|
1679
|
-
const {
|
|
1680
|
-
instance_id,
|
|
1681
|
-
summary,
|
|
1682
|
-
files_changed = [],
|
|
1683
|
-
lessons_learned,
|
|
1684
|
-
workspace_path = '',
|
|
1685
|
-
} = args as {
|
|
1686
|
-
instance_id: string;
|
|
1687
|
-
summary: string;
|
|
1688
|
-
files_changed?: string[];
|
|
1689
|
-
lessons_learned?: number;
|
|
1690
|
-
workspace_path?: string;
|
|
1691
|
-
};
|
|
1692
|
-
|
|
1693
|
-
const redis = await getConnection(instance_id);
|
|
1694
|
-
const now = new Date();
|
|
1695
|
-
|
|
1696
|
-
// Calculate duration from session_start marker
|
|
1697
|
-
let durationMin: number | undefined;
|
|
1698
|
-
const currentRaw = await redis.get('cachly:session:current');
|
|
1699
|
-
if (!currentRaw) {
|
|
1700
|
-
return `⚠️ No active session found.\n\nRun \`session_start\` first to begin tracking a session, then call \`session_end\` when you're done.`;
|
|
1701
|
-
}
|
|
1702
|
-
const current = safeJsonParse<{ started: string } | null>(currentRaw, null);
|
|
1703
|
-
if (current?.started) {
|
|
1704
|
-
durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
// ── Session Replay: capture decision log ─────────────────────────────
|
|
1708
|
-
type DecisionEntry = { ts: string; topic: string; outcome: string; what_worked: string };
|
|
1709
|
-
let decisionLog: DecisionEntry[] = [];
|
|
1710
|
-
try {
|
|
1711
|
-
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
1712
|
-
decisionLog = dlEntries.flatMap(e => {
|
|
1713
|
-
const entry = safeJsonParse<DecisionEntry | null>(e, null);
|
|
1714
|
-
return entry ? [entry] : [];
|
|
1715
|
-
});
|
|
1716
|
-
await redis.del('cachly:session:decision-log');
|
|
1717
|
-
} catch { /* non-critical */ }
|
|
1718
|
-
|
|
1719
|
-
const sessionRecord = {
|
|
1720
|
-
ts: now.toISOString(),
|
|
1721
|
-
summary,
|
|
1722
|
-
files_changed,
|
|
1723
|
-
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
1724
|
-
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
1725
|
-
...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
|
|
1726
|
-
};
|
|
1727
|
-
|
|
1728
|
-
// Save as "last session"
|
|
1729
|
-
await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
|
|
1730
|
-
|
|
1731
|
-
// Append to history list (keep last 50 sessions, TTL 90 days)
|
|
1732
|
-
await redis.lpush('cachly:session:history', JSON.stringify(sessionRecord));
|
|
1733
|
-
await redis.ltrim('cachly:session:history', 0, 49);
|
|
1734
|
-
await redis.expire('cachly:session:history', 90 * 86400);
|
|
1735
|
-
|
|
1736
|
-
// Clean up current session marker
|
|
1737
|
-
await redis.del('cachly:session:current');
|
|
1738
|
-
|
|
1739
|
-
// ── AUTO-LEARN from session summary (no manual call needed) ─────────────
|
|
1740
|
-
// Parse the summary for actionable lessons and store them automatically.
|
|
1741
|
-
const autoLearned: string[] = [];
|
|
1742
|
-
try {
|
|
1743
|
-
// Extract key sentences from the summary that contain action verbs
|
|
1744
|
-
const actionVerbs = /\b(fixed|deployed|added|removed|refactored|migrated|updated|resolved|implemented|improved|optimized|configured|created|deleted|disabled|enabled|discovered|found|learned|debugged|patched|upgraded|installed|tested|built|rewrote|moved|renamed|split|merged|extracted)\b/i;
|
|
1745
|
-
const sentences = summary
|
|
1746
|
-
.split(/[.!\n]+/)
|
|
1747
|
-
.map(s => s.trim())
|
|
1748
|
-
.filter(s => s.length > 20 && actionVerbs.test(s));
|
|
1749
|
-
|
|
1750
|
-
for (const sentence of sentences.slice(0, 6)) {
|
|
1751
|
-
// Build a topic slug from the first meaningful words
|
|
1752
|
-
const words = sentence.toLowerCase()
|
|
1753
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
1754
|
-
.split(/\s+/)
|
|
1755
|
-
.filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
|
|
1756
|
-
const slug = words.slice(0, 4).join('-');
|
|
1757
|
-
if (!slug) continue;
|
|
1758
|
-
const topic = `auto:${slug}`;
|
|
1759
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
1760
|
-
|
|
1761
|
-
// Don't overwrite existing successful lessons
|
|
1762
|
-
const existing = await redis.get(key);
|
|
1763
|
-
if (existing) {
|
|
1764
|
-
try {
|
|
1765
|
-
const ex = JSON.parse(existing) as { outcome: string };
|
|
1766
|
-
if (ex.outcome === 'success') continue;
|
|
1767
|
-
} catch { /* ignore */ }
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
const lesson = {
|
|
1771
|
-
topic,
|
|
1772
|
-
outcome: 'success',
|
|
1773
|
-
what_worked: sentence,
|
|
1774
|
-
context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
|
|
1775
|
-
severity: 'minor',
|
|
1776
|
-
ts: now.toISOString(),
|
|
1777
|
-
recall_count: 0,
|
|
1778
|
-
auto_learned: true,
|
|
1779
|
-
session_ts: now.toISOString(),
|
|
1780
|
-
version: 2,
|
|
1781
|
-
};
|
|
1782
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
1783
|
-
// 90-day TTL for auto-learned lessons
|
|
1784
|
-
await redis.expire(key, 90 * 86400);
|
|
1785
|
-
autoLearned.push(topic);
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
// Also store a lesson per changed file area if files were changed
|
|
1789
|
-
if (files_changed.length > 0) {
|
|
1790
|
-
const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
|
|
1791
|
-
for (const area of areas) {
|
|
1792
|
-
const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
|
|
1793
|
-
const topic = `auto:changed:${slug}`;
|
|
1794
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
1795
|
-
const lesson = {
|
|
1796
|
-
topic,
|
|
1797
|
-
outcome: 'success',
|
|
1798
|
-
what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
|
|
1799
|
-
context: summary.slice(0, 200),
|
|
1800
|
-
severity: 'minor',
|
|
1801
|
-
ts: now.toISOString(),
|
|
1802
|
-
recall_count: 0,
|
|
1803
|
-
auto_learned: true,
|
|
1804
|
-
version: 2,
|
|
1805
|
-
};
|
|
1806
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
1807
|
-
await redis.expire(key, 90 * 86400);
|
|
1808
|
-
autoLearned.push(topic);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
} catch { /* auto-learn errors must never break session_end */ }
|
|
1812
|
-
|
|
1813
|
-
// ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
|
|
1814
|
-
// Read git commits since session start → auto-learn each meaningful commit.
|
|
1815
|
-
const ambientLearned: string[] = [];
|
|
1816
|
-
if (workspace_path) {
|
|
1817
|
-
try {
|
|
1818
|
-
// Get the session start time (stored by session_start)
|
|
1819
|
-
const sessionStartTs = currentRaw
|
|
1820
|
-
? (() => { try { return (JSON.parse(currentRaw) as { started?: string }).started ?? ''; } catch { return ''; } })()
|
|
1821
|
-
: '';
|
|
1822
|
-
const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
|
|
1823
|
-
const gitOut = execSync(
|
|
1824
|
-
`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`,
|
|
1825
|
-
{ timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
1826
|
-
).trim();
|
|
1827
|
-
if (gitOut) {
|
|
1828
|
-
const commitActionRe = /\b(fix|add|remove|refactor|migrate|update|resolve|implement|improve|optimize|configure|create|delete|disable|enable|debug|patch|upgrade|build|rewrite|deploy|feat|chore|docs|test|perf|ci)\b/i;
|
|
1829
|
-
for (const line of gitOut.split('\n').slice(0, 10)) {
|
|
1830
|
-
const [hash, msg, dateStr] = line.split('|||');
|
|
1831
|
-
if (!msg || !commitActionRe.test(msg)) continue;
|
|
1832
|
-
const slug = msg
|
|
1833
|
-
.toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
|
|
1834
|
-
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
1835
|
-
if (!slug) continue;
|
|
1836
|
-
const topic = `git:${slug}`;
|
|
1837
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
1838
|
-
const existing = await redis.get(key);
|
|
1839
|
-
if (existing) continue; // don't overwrite existing
|
|
1840
|
-
const commitLesson = {
|
|
1841
|
-
topic,
|
|
1842
|
-
outcome: 'success' as const,
|
|
1843
|
-
what_worked: msg.slice(0, 200),
|
|
1844
|
-
context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
|
|
1845
|
-
severity: 'minor' as const,
|
|
1846
|
-
ts: now.toISOString(),
|
|
1847
|
-
recall_count: 0,
|
|
1848
|
-
auto_learned: true,
|
|
1849
|
-
source: 'ambient-git',
|
|
1850
|
-
version: 3,
|
|
1851
|
-
};
|
|
1852
|
-
await redis.set(key, JSON.stringify(commitLesson));
|
|
1853
|
-
await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
|
|
1854
|
-
ambientLearned.push(topic);
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
} catch { /* git not available or not a repo — silent skip */ }
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
1861
|
-
const totalAutoLearned = autoLearned.length + ambientLearned.length;
|
|
1862
|
-
|
|
1863
|
-
// ── Shareable Session Summary Card ────────────────────────────────────────
|
|
1864
|
-
// Generated after each session so the user can share their progress.
|
|
1865
|
-
const tweetLines: string[] = [];
|
|
1866
|
-
if (durationMin !== undefined && durationMin > 0) tweetLines.push(`⏱ ${durationMin} min session`);
|
|
1867
|
-
if (totalAutoLearned > 0) tweetLines.push(`🧠 ${totalAutoLearned} lessons saved to Brain`);
|
|
1868
|
-
if (files_changed.length > 0) tweetLines.push(`📁 ${files_changed.length} file${files_changed.length > 1 ? 's' : ''} changed`);
|
|
1869
|
-
const tweetBody = tweetLines.length > 0
|
|
1870
|
-
? `${tweetLines.join(' · ')}\n\nMy AI Brain remembers this so I never repeat it. @cachlydev\ncachly.dev`
|
|
1871
|
-
: `Session saved to my AI Brain. No more re-explaining this tomorrow. @cachlydev\ncachly.dev`;
|
|
1872
|
-
const tweetURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetBody)}`;
|
|
1873
|
-
|
|
1874
|
-
const sessionCard = [
|
|
1875
|
-
``,
|
|
1876
|
-
`┌─────────────────────────────────────────────┐`,
|
|
1877
|
-
`│ 🧠 Session Summary Card │`,
|
|
1878
|
-
`│ Share your progress — cached forever │`,
|
|
1879
|
-
`├─────────────────────────────────────────────┤`,
|
|
1880
|
-
durationMin !== undefined ? `│ ⏱ Duration : ${String(durationMin + ' min').padEnd(26)}│` : '',
|
|
1881
|
-
totalAutoLearned > 0 ? `│ 📚 Learned : ${String(totalAutoLearned + ' lessons').padEnd(26)}│` : '',
|
|
1882
|
-
files_changed.length > 0 ? `│ 📁 Changed : ${String(files_changed.length + ' file' + (files_changed.length > 1 ? 's' : '')).padEnd(26)}│` : '',
|
|
1883
|
-
`├─────────────────────────────────────────────┤`,
|
|
1884
|
-
`│ 📣 Share: ${tweetURL.slice(0, 34).padEnd(34)}│`,
|
|
1885
|
-
`└─────────────────────────────────────────────┘`,
|
|
1886
|
-
].filter(l => l !== '').join('\n');
|
|
1887
|
-
|
|
1888
|
-
return [
|
|
1889
|
-
`✅ **Session saved**${durationStr}`,
|
|
1890
|
-
``,
|
|
1891
|
-
`📋 **Summary:** ${summary}`,
|
|
1892
|
-
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
1893
|
-
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
1894
|
-
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
1895
|
-
ambientLearned.length > 0 ? `🌿 **Ambient git learning:** ${ambientLearned.length} commit${ambientLearned.length > 1 ? 's' : ''} auto-learned (${ambientLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${ambientLearned.length > 3 ? '…' : ''})` : '',
|
|
1896
|
-
sessionCard,
|
|
1897
|
-
``,
|
|
1898
|
-
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
1899
|
-
].filter(l => l !== '').join('\n');
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// ── session_ping — lightweight checkpoint ─────────────────────────────────
|
|
1903
|
-
case 'session_ping': {
|
|
1904
|
-
const {
|
|
1905
|
-
instance_id,
|
|
1906
|
-
task,
|
|
1907
|
-
files_touched = [],
|
|
1908
|
-
next_step = '',
|
|
1909
|
-
provider = '',
|
|
1910
|
-
} = args as {
|
|
1911
|
-
instance_id: string;
|
|
1912
|
-
task: string;
|
|
1913
|
-
files_touched?: string[];
|
|
1914
|
-
next_step?: string;
|
|
1915
|
-
provider?: string;
|
|
1916
|
-
};
|
|
1917
|
-
|
|
1918
|
-
const redis = await getConnection(instance_id);
|
|
1919
|
-
const checkpoint = {
|
|
1920
|
-
ts: new Date().toISOString(),
|
|
1921
|
-
task,
|
|
1922
|
-
files_touched,
|
|
1923
|
-
next_step,
|
|
1924
|
-
provider,
|
|
1925
|
-
};
|
|
1926
|
-
|
|
1927
|
-
// Store as the latest checkpoint — session_start reads this when no session_end found
|
|
1928
|
-
await redis.set('cachly:session:checkpoint', JSON.stringify(checkpoint), 'EX', 86400 * 3); // 3-day TTL
|
|
1929
|
-
|
|
1930
|
-
// Also keep a short rolling log (last 20 checkpoints for history)
|
|
1931
|
-
await redis.lpush('cachly:session:checkpoint:log', JSON.stringify(checkpoint));
|
|
1932
|
-
await redis.ltrim('cachly:session:checkpoint:log', 0, 19);
|
|
1933
|
-
|
|
1934
|
-
const providerStr = provider ? ` [${provider}]` : '';
|
|
1935
|
-
const filesStr = files_touched.length > 0 ? ` · ${files_touched.length} file${files_touched.length > 1 ? 's' : ''} touched` : '';
|
|
1936
|
-
const nextStr = next_step ? `\n📍 **Next step:** ${next_step}` : '';
|
|
1937
|
-
|
|
1938
|
-
return [
|
|
1939
|
-
`📌 **Checkpoint saved**${providerStr} — ${new Date().toLocaleTimeString()}`,
|
|
1940
|
-
`🔨 **Working on:** ${task}${filesStr}`,
|
|
1941
|
-
nextStr,
|
|
1942
|
-
``,
|
|
1943
|
-
`💡 If you switch providers, \`session_start\` will show this checkpoint automatically.`,
|
|
1944
|
-
].filter(l => l !== '').join('\n');
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
// ── session_handoff — cross-window continuity ─────────────────────────────
|
|
1948
|
-
case 'session_handoff': {
|
|
1949
|
-
const {
|
|
1950
|
-
instance_id,
|
|
1951
|
-
completed_tasks = [],
|
|
1952
|
-
remaining_tasks = [],
|
|
1953
|
-
files_changed = [],
|
|
1954
|
-
instructions = '',
|
|
1955
|
-
context_summary = '',
|
|
1956
|
-
blocked_on = '',
|
|
1957
|
-
} = args as {
|
|
1958
|
-
instance_id: string;
|
|
1959
|
-
completed_tasks: string[];
|
|
1960
|
-
remaining_tasks: string[];
|
|
1961
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
1962
|
-
instructions?: string;
|
|
1963
|
-
context_summary?: string;
|
|
1964
|
-
blocked_on?: string;
|
|
1965
|
-
};
|
|
1966
|
-
|
|
1967
|
-
const redis = await getConnection(instance_id);
|
|
1968
|
-
const now = new Date();
|
|
1969
|
-
|
|
1970
|
-
const handoff = {
|
|
1971
|
-
ts: now.toISOString(),
|
|
1972
|
-
completed_tasks,
|
|
1973
|
-
remaining_tasks,
|
|
1974
|
-
files_changed,
|
|
1975
|
-
instructions,
|
|
1976
|
-
context_summary,
|
|
1977
|
-
blocked_on,
|
|
1978
|
-
};
|
|
1979
|
-
|
|
1980
|
-
// Store handoff — never expires until next handoff overwrites it
|
|
1981
|
-
await redis.set('cachly:session:handoff', JSON.stringify(handoff));
|
|
1982
|
-
|
|
1983
|
-
// Also append to history
|
|
1984
|
-
await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
|
|
1985
|
-
await redis.ltrim('cachly:session:handoff:history', 0, 19);
|
|
1986
|
-
|
|
1987
|
-
const totalTasks = completed_tasks.length + remaining_tasks.length;
|
|
1988
|
-
const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
|
|
1989
|
-
const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
|
|
1990
|
-
|
|
1991
|
-
return [
|
|
1992
|
-
`🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
|
|
1993
|
-
``,
|
|
1994
|
-
completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
1995
|
-
remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
1996
|
-
brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
|
|
1997
|
-
blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
|
|
1998
|
-
instructions ? `\n📝 **Instructions:** ${instructions}` : '',
|
|
1999
|
-
``,
|
|
2000
|
-
`💡 The next \`session_start\` will include this handoff automatically.`,
|
|
2001
|
-
].filter(l => l !== '').join('\n');
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
2005
|
-
case 'auto_learn_session': {
|
|
2006
|
-
const { instance_id, observations } = args as {
|
|
2007
|
-
instance_id: string;
|
|
2008
|
-
observations: { action: string; outcome: string; details?: string; topic?: string; severity?: string }[];
|
|
2009
|
-
};
|
|
2010
|
-
const redis = await getConnection(instance_id);
|
|
2011
|
-
const stored: string[] = [];
|
|
2012
|
-
const skipped: string[] = [];
|
|
2013
|
-
|
|
2014
|
-
for (const obs of observations) {
|
|
2015
|
-
// Auto-generate topic from action if not provided
|
|
2016
|
-
const rawTopic = obs.topic ?? obs.action
|
|
2017
|
-
.toLowerCase()
|
|
2018
|
-
.replace(/[^a-z0-9:\-_\s]/g, '')
|
|
2019
|
-
.trim()
|
|
2020
|
-
.split(/\s+/)
|
|
2021
|
-
.slice(0, 4)
|
|
2022
|
-
.join('-');
|
|
2023
|
-
const topic = rawTopic.includes(':') ? rawTopic : `auto:${rawTopic}`;
|
|
2024
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
2025
|
-
|
|
2026
|
-
// Only overwrite if this is a success and existing is failure, or topic is new
|
|
2027
|
-
const existing = await redis.get(key);
|
|
2028
|
-
if (existing) {
|
|
2029
|
-
const existingLesson = JSON.parse(existing) as { outcome: string };
|
|
2030
|
-
if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
|
|
2031
|
-
skipped.push(topic);
|
|
2032
|
-
continue;
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
const lesson = {
|
|
2037
|
-
topic,
|
|
2038
|
-
outcome: obs.outcome,
|
|
2039
|
-
what_worked: obs.outcome === 'success' ? obs.action : (obs.details ?? obs.action),
|
|
2040
|
-
what_failed: obs.outcome === 'failure' ? obs.action : undefined,
|
|
2041
|
-
context: obs.details,
|
|
2042
|
-
severity: obs.severity ?? 'minor',
|
|
2043
|
-
ts: new Date().toISOString(),
|
|
2044
|
-
recall_count: 0,
|
|
2045
|
-
auto_learned: true,
|
|
2046
|
-
version: 2,
|
|
2047
|
-
};
|
|
2048
|
-
|
|
2049
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
2050
|
-
stored.push(`${obs.outcome === 'success' ? '✅' : obs.outcome === 'partial' ? '⚠️' : '❌'} \`${topic}\``);
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
const lines = [
|
|
2054
|
-
`🤖 **Auto-learn complete**: ${stored.length} stored, ${skipped.length} skipped`,
|
|
2055
|
-
'',
|
|
2056
|
-
];
|
|
2057
|
-
if (stored.length > 0) lines.push('**Stored:**', ...stored.map(s => ' ' + s), '');
|
|
2058
|
-
if (skipped.length > 0) lines.push(`**Skipped** (better lesson already exists): ${skipped.map(t => `\`${t}\``).join(', ')}`);
|
|
2059
|
-
return lines.join('\n');
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// ── brain_who_knows ───────────────────────────────────────────────────────
|
|
2063
|
-
// Phase 3A: Org-wide people intelligence. Returns ranked list of contributors
|
|
2064
|
-
// whose authored edges in the CKG match the query topic.
|
|
2065
|
-
case 'brain_who_knows': {
|
|
2066
|
-
const {
|
|
2067
|
-
instance_id,
|
|
2068
|
-
topic,
|
|
2069
|
-
limit = 10,
|
|
2070
|
-
} = args as { instance_id: string; topic: string; limit?: number };
|
|
2071
|
-
|
|
2072
|
-
if (!topic || typeof topic !== 'string' || !topic.trim()) {
|
|
2073
|
-
return '⚠️ `brain_who_knows` requires a non-empty `topic`. Example: `brain_who_knows(topic="kubernetes deployment")`.';
|
|
2074
|
-
}
|
|
2075
|
-
const safeLimit = Math.max(1, Math.min(Number(limit) || 10, 50));
|
|
2076
|
-
|
|
2077
|
-
const redis = await getConnection(instance_id);
|
|
2078
|
-
|
|
2079
|
-
// Step 1: Find candidate concept node IDs matching the query
|
|
2080
|
-
const querySlug = ckgSlug(topic);
|
|
2081
|
-
const qTokens = tokenize(topic).filter((t: string) => t.length >= 3).slice(0, 5);
|
|
2082
|
-
|
|
2083
|
-
const candidateConceptIds = new Set<string>([querySlug]);
|
|
2084
|
-
for (const token of qTokens.slice(0, 3)) {
|
|
2085
|
-
const nodeStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 30 });
|
|
2086
|
-
await new Promise<void>((res, rej) => {
|
|
2087
|
-
nodeStream.on('data', (batch: string[]) => {
|
|
2088
|
-
for (const k of batch.slice(0, 10)) {
|
|
2089
|
-
const nodeId = k.replace('cachly:ckg:node:', '');
|
|
2090
|
-
if (!nodeId.startsWith('person:') && !nodeId.startsWith('file:'))
|
|
2091
|
-
candidateConceptIds.add(nodeId);
|
|
2092
|
-
}
|
|
2093
|
-
});
|
|
2094
|
-
nodeStream.on('end', res);
|
|
2095
|
-
nodeStream.on('error', rej);
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// Step 2: Traverse authored edges (person → concept) to find experts
|
|
2100
|
-
type PersonScore = {
|
|
2101
|
-
handle: string; lessonCount: number; totalConfidence: number;
|
|
2102
|
-
domains: Set<string>; lastActive: string;
|
|
2103
|
-
};
|
|
2104
|
-
const personScores = new Map<string, PersonScore>();
|
|
2105
|
-
|
|
2106
|
-
for (const conceptId of [...candidateConceptIds].slice(0, 25)) {
|
|
2107
|
-
const inboundEdgeKeys = await redis.smembers(`cachly:ckg:idx:to:${conceptId}`);
|
|
2108
|
-
for (const ek of inboundEdgeKeys) {
|
|
2109
|
-
const er = await redis.get(ek);
|
|
2110
|
-
if (!er) continue;
|
|
2111
|
-
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
2112
|
-
if (!edge || edge.edgeType !== 'authored') continue;
|
|
2113
|
-
|
|
2114
|
-
const personNodeRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
|
|
2115
|
-
if (!personNodeRaw) continue;
|
|
2116
|
-
const personNode = safeJsonParse<PersonNode | null>(personNodeRaw, null);
|
|
2117
|
-
if (!personNode || personNode.type !== 'person') continue;
|
|
2118
|
-
|
|
2119
|
-
const existing = personScores.get(edge.from);
|
|
2120
|
-
if (existing) {
|
|
2121
|
-
existing.lessonCount++;
|
|
2122
|
-
existing.totalConfidence += edge.confidence;
|
|
2123
|
-
existing.domains.add(personNode.domain);
|
|
2124
|
-
if (edge.last_updated > existing.lastActive) existing.lastActive = edge.last_updated;
|
|
2125
|
-
} else {
|
|
2126
|
-
personScores.set(edge.from, {
|
|
2127
|
-
handle: personNode.handle,
|
|
2128
|
-
lessonCount: 1,
|
|
2129
|
-
totalConfidence: edge.confidence,
|
|
2130
|
-
domains: new Set([personNode.domain]),
|
|
2131
|
-
lastActive: personNode.last_active ?? edge.last_updated,
|
|
2132
|
-
});
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
if (personScores.size === 0) {
|
|
2138
|
-
return [
|
|
2139
|
-
`## 👥 Who Knows About \`${topic}\`?`,
|
|
2140
|
-
``,
|
|
2141
|
-
`No attributed lessons found yet.`,
|
|
2142
|
-
``,
|
|
2143
|
-
`Knowledge attribution builds automatically — add \`author="name"\` to \`learn_from_attempts\` calls:`,
|
|
2144
|
-
`\`learn_from_attempts(topic="${topic}", author="your-handle", ...)\``,
|
|
2145
|
-
].join('\n');
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
// Step 3: Sort by expertise score (lesson count × avg confidence)
|
|
2149
|
-
const ranked = [...personScores.entries()]
|
|
2150
|
-
.map(([, s]) => ({
|
|
2151
|
-
handle: s.handle,
|
|
2152
|
-
lessonCount: s.lessonCount,
|
|
2153
|
-
avgConfidence: s.totalConfidence / s.lessonCount,
|
|
2154
|
-
domains: [...s.domains].slice(0, 3).join(', '),
|
|
2155
|
-
lastActive: s.lastActive,
|
|
2156
|
-
}))
|
|
2157
|
-
.sort((a, b) => (b.lessonCount * b.avgConfidence) - (a.lessonCount * a.avgConfidence))
|
|
2158
|
-
.slice(0, safeLimit);
|
|
2159
|
-
|
|
2160
|
-
const lines = [
|
|
2161
|
-
`## 👥 Who Knows About \`${topic}\`?`,
|
|
2162
|
-
``,
|
|
2163
|
-
`**${ranked.length}** contributor${ranked.length !== 1 ? 's' : ''} with relevant lessons:`,
|
|
2164
|
-
``,
|
|
2165
|
-
];
|
|
2166
|
-
|
|
2167
|
-
for (const [i, p] of ranked.entries()) {
|
|
2168
|
-
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
2169
|
-
const conf = (p.avgConfidence * 100).toFixed(0);
|
|
2170
|
-
const daysSince = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
|
|
2171
|
-
const recency = daysSince < 1 ? 'today' : daysSince < 7 ? `${daysSince}d ago` : daysSince < 30 ? `${Math.floor(daysSince / 7)}w ago` : `${Math.floor(daysSince / 30)}mo ago`;
|
|
2172
|
-
lines.push(
|
|
2173
|
-
`${medal} **${p.handle}** — ${p.lessonCount} lesson${p.lessonCount !== 1 ? 's' : ''} · ${conf}% confidence · last active ${recency}`,
|
|
2174
|
-
` _domains: ${p.domains}_`,
|
|
2175
|
-
``,
|
|
2176
|
-
);
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
lines.push(
|
|
2180
|
-
`---`,
|
|
2181
|
-
`_Attribution grows with every \`learn_from_attempts(author="...", ...)\` call._`,
|
|
2182
|
-
);
|
|
2183
|
-
|
|
2184
|
-
return lines.join('\n');
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
// ── brain_file_map ────────────────────────────────────────────────────────
|
|
2188
|
-
// Phase 3B: "What do we know about these files?" — experts + lessons per file.
|
|
2189
|
-
case 'brain_file_map': {
|
|
2190
|
-
const {
|
|
2191
|
-
instance_id,
|
|
2192
|
-
file_paths = [],
|
|
2193
|
-
} = args as { instance_id: string; file_paths: string[] };
|
|
2194
|
-
|
|
2195
|
-
// Defensive: accept only a non-empty array of non-empty strings.
|
|
2196
|
-
const cleanPaths = (Array.isArray(file_paths) ? file_paths : [])
|
|
2197
|
-
.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
|
|
2198
|
-
.map(p => p.trim());
|
|
2199
|
-
if (!cleanPaths.length) return '⚠️ Pass at least one file path. Example: `brain_file_map(file_paths=["src/auth/jwt.ts"])`.';
|
|
2200
|
-
|
|
2201
|
-
const redis = await getConnection(instance_id);
|
|
2202
|
-
const lines: string[] = [
|
|
2203
|
-
`## 📁 File Knowledge Map`,
|
|
2204
|
-
``,
|
|
2205
|
-
`_What cachly knows about the files you're about to touch:_`,
|
|
2206
|
-
``,
|
|
2207
|
-
];
|
|
2208
|
-
|
|
2209
|
-
for (const fp of cleanPaths.slice(0, 10)) {
|
|
2210
|
-
lines.push(`### \`${fp}\``);
|
|
2211
|
-
|
|
2212
|
-
// ── Experts via CKG file nodes ─────────────────────────────────────
|
|
2213
|
-
const fileId = `file:${ckgSlug(fp)}`;
|
|
2214
|
-
const inbound = await redis.smembers(`cachly:ckg:idx:to:${fileId}`);
|
|
2215
|
-
type ExpertEntry = { handle: string; touches: number; lastSeen: string };
|
|
2216
|
-
const experts: ExpertEntry[] = [];
|
|
2217
|
-
for (const ek of inbound) {
|
|
2218
|
-
const er = await redis.get(ek);
|
|
2219
|
-
if (!er) continue;
|
|
2220
|
-
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
2221
|
-
if (!edge || edge.edgeType !== 'touched') continue;
|
|
2222
|
-
const personRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
|
|
2223
|
-
if (!personRaw) continue;
|
|
2224
|
-
const pn = safeJsonParse<PersonNode | null>(personRaw, null);
|
|
2225
|
-
if (!pn || pn.type !== 'person') continue;
|
|
2226
|
-
experts.push({ handle: pn.handle, touches: edge.trials, lastSeen: edge.last_updated });
|
|
2227
|
-
}
|
|
2228
|
-
experts.sort((a, b) => b.touches - a.touches);
|
|
2229
|
-
|
|
2230
|
-
if (experts.length > 0) {
|
|
2231
|
-
const expLine = experts.slice(0, 5).map((e, i) => {
|
|
2232
|
-
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
2233
|
-
const days = Math.floor((Date.now() - new Date(e.lastSeen).getTime()) / 86400000);
|
|
2234
|
-
const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : `${Math.floor(days / 7)}w ago`;
|
|
2235
|
-
return `${medal} **${e.handle}** (${e.touches}× · ${ago})`;
|
|
2236
|
-
}).join(' · ');
|
|
2237
|
-
lines.push(`**Experts:** ${expLine}`);
|
|
2238
|
-
} else {
|
|
2239
|
-
lines.push(`**Experts:** _None yet — add \`author\` to \`learn_from_attempts\` calls_`);
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
// ── Related lessons via BM25 on filename tokens ────────────────────
|
|
2243
|
-
const tokens = fp.replace(/[^a-z0-9]/gi, ' ').split(/\s+/).filter(t => t.length >= 4);
|
|
2244
|
-
const lessonQuery = tokens.slice(0, 3).join(' ');
|
|
2245
|
-
const relatedLessons: string[] = [];
|
|
2246
|
-
if (lessonQuery) {
|
|
2247
|
-
try {
|
|
2248
|
-
const hits = await keywordSearch(redis, ['cachly:lesson:best:*'], lessonQuery, 4);
|
|
2249
|
-
for (const h of hits) {
|
|
2250
|
-
const ld = safeJsonParse<{
|
|
2251
|
-
topic?: string; outcome?: string; what_worked?: string;
|
|
2252
|
-
severity?: string; author?: string; visibility?: string;
|
|
2253
|
-
}>(h.content, {});
|
|
2254
|
-
if (ld.visibility === 'private') continue;
|
|
2255
|
-
const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
|
|
2256
|
-
const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
|
|
2257
|
-
const by = ld.author ? ` · 👤 ${ld.author}` : '';
|
|
2258
|
-
const topic = h.key.replace('cachly:lesson:best:', '');
|
|
2259
|
-
relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
|
|
2260
|
-
}
|
|
2261
|
-
} catch { /* non-critical */ }
|
|
2262
|
-
}
|
|
2263
|
-
// Also: exact file_paths match via lesson scan
|
|
2264
|
-
if (relatedLessons.length < 2) {
|
|
2265
|
-
const allLessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 1500 });
|
|
2266
|
-
for (const k of allLessonKeys.slice(0, 300)) {
|
|
2267
|
-
const raw = await redis.get(k);
|
|
2268
|
-
if (!raw) continue;
|
|
2269
|
-
const ld = safeJsonParse<{
|
|
2270
|
-
topic?: string; outcome?: string; what_worked?: string;
|
|
2271
|
-
severity?: string; author?: string; visibility?: string; file_paths?: string[];
|
|
2272
|
-
}>(raw, {});
|
|
2273
|
-
if (ld.visibility === 'private') continue;
|
|
2274
|
-
if (!(ld.file_paths ?? []).includes(fp)) continue;
|
|
2275
|
-
const topic = k.replace('cachly:lesson:best:', '');
|
|
2276
|
-
if (relatedLessons.some(l => l.includes(`\`${topic}\``))) continue;
|
|
2277
|
-
const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
|
|
2278
|
-
const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
|
|
2279
|
-
const by = ld.author ? ` · 👤 ${ld.author}` : '';
|
|
2280
|
-
relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
if (relatedLessons.length > 0) {
|
|
2285
|
-
lines.push(`**Related lessons:**`);
|
|
2286
|
-
lines.push(...relatedLessons.slice(0, 5));
|
|
2287
|
-
} else {
|
|
2288
|
-
lines.push(`**Related lessons:** _None yet_`);
|
|
2289
|
-
}
|
|
2290
|
-
lines.push(``);
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
lines.push(
|
|
2294
|
-
`---`,
|
|
2295
|
-
`_Attribution builds with \`learn_from_attempts(file_paths=[...], author="...")\`. Run this before committing to track what changed and why._`,
|
|
2296
|
-
);
|
|
2297
|
-
return lines.join('\n');
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
// ── team_expertise_map ────────────────────────────────────────────────────
|
|
2301
|
-
// Phase 3B: Full team expertise overview — who knows what, at a glance.
|
|
2302
|
-
case 'team_expertise_map': {
|
|
2303
|
-
const {
|
|
2304
|
-
instance_id,
|
|
2305
|
-
top_n = 20,
|
|
2306
|
-
} = args as { instance_id: string; top_n?: number };
|
|
2307
|
-
|
|
2308
|
-
const redis = await getConnection(instance_id);
|
|
2309
|
-
|
|
2310
|
-
// Scan all person nodes (capped + timed out so a huge keyspace can't hang the agent)
|
|
2311
|
-
const personKeys = await scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 });
|
|
2312
|
-
|
|
2313
|
-
if (personKeys.length === 0) {
|
|
2314
|
-
return [
|
|
2315
|
-
`## 🗺️ Team Expertise Map`,
|
|
2316
|
-
``,
|
|
2317
|
-
`No contributors yet.`,
|
|
2318
|
-
``,
|
|
2319
|
-
`Add \`author="handle"\` to any \`learn_from_attempts\` call to start building the map.`,
|
|
2320
|
-
].join('\n');
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
type PersonEntry = { handle: string; lessonCount: number; domains: Map<string, number>; lastActive: string };
|
|
2324
|
-
const people: PersonEntry[] = [];
|
|
2325
|
-
|
|
2326
|
-
for (const pk of personKeys) {
|
|
2327
|
-
const raw = await redis.get(pk);
|
|
2328
|
-
if (!raw) continue;
|
|
2329
|
-
const pn = safeJsonParse<PersonNode | null>(raw, null);
|
|
2330
|
-
if (!pn || pn.type !== 'person') continue;
|
|
2331
|
-
|
|
2332
|
-
// Count authored lessons + collect domains from authored edges
|
|
2333
|
-
const outEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${pn.id}`);
|
|
2334
|
-
const domainCounts = new Map<string, number>();
|
|
2335
|
-
let lessonCount = 0;
|
|
2336
|
-
for (const ek of outEdgeKeys) {
|
|
2337
|
-
const er = await redis.get(ek);
|
|
2338
|
-
if (!er) continue;
|
|
2339
|
-
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
2340
|
-
if (!edge || edge.edgeType !== 'authored') continue;
|
|
2341
|
-
lessonCount++;
|
|
2342
|
-
const domain = edge.to.split(':')[0] ?? 'unknown';
|
|
2343
|
-
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
2344
|
-
}
|
|
2345
|
-
people.push({ handle: pn.handle, lessonCount, domains: domainCounts, lastActive: pn.last_active });
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
people.sort((a, b) => b.lessonCount - a.lessonCount);
|
|
2349
|
-
const topPeople = people.slice(0, top_n);
|
|
2350
|
-
|
|
2351
|
-
const lines = [
|
|
2352
|
-
`## 🗺️ Team Expertise Map`,
|
|
2353
|
-
``,
|
|
2354
|
-
`**${topPeople.length}** contributor${topPeople.length !== 1 ? 's' : ''} tracked:`,
|
|
2355
|
-
``,
|
|
2356
|
-
`| # | Handle | Lessons | Top domains | Last active |`,
|
|
2357
|
-
`|---|--------|---------|-------------|-------------|`,
|
|
2358
|
-
];
|
|
2359
|
-
|
|
2360
|
-
for (const [i, p] of topPeople.entries()) {
|
|
2361
|
-
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`;
|
|
2362
|
-
const days = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
|
|
2363
|
-
const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : days < 30 ? `${Math.floor(days / 7)}w ago` : `${Math.floor(days / 30)}mo ago`;
|
|
2364
|
-
const domainStr = [...p.domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d, c]) => `${d}(${c})`).join(', ') || '—';
|
|
2365
|
-
lines.push(`| ${medal} | **${p.handle}** | ${p.lessonCount} | ${domainStr} | ${ago} |`);
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
lines.push(
|
|
2369
|
-
``,
|
|
2370
|
-
`---`,
|
|
2371
|
-
`_Use \`brain_who_knows(topic="...")\` to find the expert for a specific problem._`,
|
|
2372
|
-
);
|
|
2373
|
-
return lines.join('\n');
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
// ── skill_gaps ────────────────────────────────────────────────────────────
|
|
2377
|
-
// Phase 3C: Show knowledge blind spots — domains/files with no success lessons,
|
|
2378
|
-
// no attribution, or only failures.
|
|
2379
|
-
case 'skill_gaps': {
|
|
2380
|
-
const {
|
|
2381
|
-
instance_id,
|
|
2382
|
-
min_failures = 1,
|
|
2383
|
-
} = args as { instance_id: string; min_failures?: number };
|
|
2384
|
-
|
|
2385
|
-
const redis = await getConnection(instance_id);
|
|
2386
|
-
|
|
2387
|
-
// Scan all best lessons (capped + timed out)
|
|
2388
|
-
const lessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 3000 });
|
|
2389
|
-
|
|
2390
|
-
type DomainStats = { successes: number; failures: number; attributed: number; topics: string[] };
|
|
2391
|
-
const domainMap = new Map<string, DomainStats>();
|
|
2392
|
-
let totalAttributed = 0;
|
|
2393
|
-
let totalPrivate = 0;
|
|
2394
|
-
|
|
2395
|
-
for (const k of lessonKeys) {
|
|
2396
|
-
const raw = await redis.get(k);
|
|
2397
|
-
if (!raw) continue;
|
|
2398
|
-
const l = safeJsonParse<{
|
|
2399
|
-
topic?: string; outcome?: string; author?: string; visibility?: string;
|
|
2400
|
-
}>(raw, {});
|
|
2401
|
-
const domain = (l.topic ?? '').split(':')[0] ?? 'unknown';
|
|
2402
|
-
const s = domainMap.get(domain) ?? { successes: 0, failures: 0, attributed: 0, topics: [] };
|
|
2403
|
-
if (l.outcome === 'success' || l.outcome === 'partial') s.successes++;
|
|
2404
|
-
else s.failures++;
|
|
2405
|
-
if (l.author) { s.attributed++; totalAttributed++; }
|
|
2406
|
-
if (l.visibility === 'private') totalPrivate++;
|
|
2407
|
-
s.topics.push(l.topic ?? '');
|
|
2408
|
-
domainMap.set(domain, s);
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
// Identify gaps
|
|
2412
|
-
type Gap = { domain: string; type: string; severity: string; detail: string };
|
|
2413
|
-
const gaps: Gap[] = [];
|
|
2414
|
-
|
|
2415
|
-
for (const [domain, s] of domainMap) {
|
|
2416
|
-
if (s.failures >= min_failures && s.successes === 0) {
|
|
2417
|
-
gaps.push({
|
|
2418
|
-
domain, type: 'unresolved failures',
|
|
2419
|
-
severity: s.failures >= 3 ? '🔴 critical' : '🟡 warn',
|
|
2420
|
-
detail: `${s.failures} failure${s.failures !== 1 ? 's' : ''}, 0 solutions — use \`learn_from_attempts\` to store what fixed it`,
|
|
2421
|
-
});
|
|
2422
|
-
}
|
|
2423
|
-
if (s.successes + s.failures >= 3 && s.attributed === 0) {
|
|
2424
|
-
gaps.push({
|
|
2425
|
-
domain, type: 'no attribution',
|
|
2426
|
-
severity: '🔵 info',
|
|
2427
|
-
detail: `${s.successes + s.failures} lessons with no \`author\` field — attribution lost, \`brain_who_knows\` can't help here`,
|
|
2428
|
-
});
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
const lines = [
|
|
2433
|
-
`## 🔍 Skill Gaps — Knowledge Blind Spots`,
|
|
2434
|
-
``,
|
|
2435
|
-
`**${lessonKeys.length}** lessons scanned · **${totalAttributed}** attributed · **${gaps.length}** gap${gaps.length !== 1 ? 's' : ''} found`,
|
|
2436
|
-
totalPrivate > 0 ? `_(${totalPrivate} private lessons excluded from gap analysis)_` : '',
|
|
2437
|
-
``,
|
|
2438
|
-
].filter(Boolean);
|
|
2439
|
-
|
|
2440
|
-
if (gaps.length === 0) {
|
|
2441
|
-
lines.push(`✅ No significant knowledge gaps detected. All domains with failures have at least one success lesson.`);
|
|
2442
|
-
} else {
|
|
2443
|
-
const bySev = [
|
|
2444
|
-
...gaps.filter(g => g.severity.includes('critical')),
|
|
2445
|
-
...gaps.filter(g => g.severity.includes('warn')),
|
|
2446
|
-
...gaps.filter(g => g.severity.includes('info')),
|
|
2447
|
-
];
|
|
2448
|
-
for (const g of bySev.slice(0, 15)) {
|
|
2449
|
-
lines.push(`${g.severity} **\`${g.domain}\`** — ${g.type}`);
|
|
2450
|
-
lines.push(` ${g.detail}`);
|
|
2451
|
-
lines.push(``);
|
|
2452
|
-
}
|
|
2453
|
-
if (gaps.length > 15) lines.push(`_…and ${gaps.length - 15} more gaps. Fix critical ones first._`);
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
lines.push(
|
|
2457
|
-
`---`,
|
|
2458
|
-
`_Fix gaps with \`learn_from_attempts(topic="${gaps[0]?.domain ?? 'your:topic'}", outcome="success", what_worked="...")\`_`,
|
|
2459
|
-
`_Add \`author="name"\` to every call to enable \`brain_who_knows\` + \`team_expertise_map\`._`,
|
|
2460
|
-
);
|
|
2461
|
-
|
|
2462
|
-
return lines.join('\n');
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
// ── brain_coverage ────────────────────────────────────────────────────────
|
|
2466
|
-
// Phase 3C: Knowledge-coverage health score for the repository.
|
|
2467
|
-
case 'brain_coverage': {
|
|
2468
|
-
const {
|
|
2469
|
-
instance_id,
|
|
2470
|
-
repo_path = '.',
|
|
2471
|
-
} = args as { instance_id: string; repo_path?: string };
|
|
2472
|
-
|
|
2473
|
-
const redis = await getConnection(instance_id);
|
|
2474
|
-
|
|
2475
|
-
// Count lessons, person + file nodes (each capped + timed out, gathered in parallel)
|
|
2476
|
-
const [lessonKeys, personKeys, fileKeys] = await Promise.all([
|
|
2477
|
-
scanKeys(redis, 'cachly:lesson:best:*', { max: 5000 }),
|
|
2478
|
-
scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 }),
|
|
2479
|
-
scanKeys(redis, 'cachly:ckg:node:file:*', { max: 5000 }),
|
|
2480
|
-
]);
|
|
2481
|
-
|
|
2482
|
-
// Get git file count for comparison (optional — non-critical if not a git repo)
|
|
2483
|
-
let gitFileCount = 0;
|
|
2484
|
-
let _gitFileCountLabel = 'n/a';
|
|
2485
|
-
try {
|
|
2486
|
-
const { execSync } = await import('node:child_process');
|
|
2487
|
-
const { resolve } = await import('node:path');
|
|
2488
|
-
const repoDir = resolve(repo_path);
|
|
2489
|
-
const output = execSync('git ls-files', { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
2490
|
-
gitFileCount = output.trim().split('\n').filter(Boolean).length;
|
|
2491
|
-
_gitFileCountLabel = String(gitFileCount);
|
|
2492
|
-
} catch { /* not a git repo — skip */ }
|
|
2493
|
-
|
|
2494
|
-
// Lesson quality breakdown
|
|
2495
|
-
let successCount = 0; let failureCount = 0; let attributedCount = 0;
|
|
2496
|
-
const domainSet = new Set<string>();
|
|
2497
|
-
for (const k of lessonKeys) {
|
|
2498
|
-
const raw = await redis.get(k);
|
|
2499
|
-
if (!raw) continue;
|
|
2500
|
-
const l = safeJsonParse<{ outcome?: string; author?: string; topic?: string }>(raw, {});
|
|
2501
|
-
if (l.outcome === 'success' || l.outcome === 'partial') successCount++;
|
|
2502
|
-
else failureCount++;
|
|
2503
|
-
if (l.author) attributedCount++;
|
|
2504
|
-
domainSet.add((l.topic ?? '').split(':')[0] ?? 'unknown');
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
const fileCoverage = gitFileCount > 0
|
|
2508
|
-
? `${Math.round((fileKeys.length / gitFileCount) * 100)}% (${fileKeys.length}/${gitFileCount} files)`
|
|
2509
|
-
: `${fileKeys.length} files tracked`;
|
|
2510
|
-
|
|
2511
|
-
const attributionPct = lessonKeys.length > 0
|
|
2512
|
-
? Math.round((attributedCount / lessonKeys.length) * 100)
|
|
2513
|
-
: 0;
|
|
2514
|
-
|
|
2515
|
-
const overallScore = Math.round(
|
|
2516
|
-
(Math.min(1, lessonKeys.length / 50) * 25) + // lesson volume (25 pts)
|
|
2517
|
-
(Math.min(1, successCount / Math.max(1, lessonKeys.length)) * 25) + // success ratio (25 pts)
|
|
2518
|
-
(Math.min(1, attributionPct / 100) * 25) + // attribution completeness (25 pts)
|
|
2519
|
-
(Math.min(1, personKeys.length / 5) * 25) // team engagement (25 pts)
|
|
2520
|
-
);
|
|
2521
|
-
|
|
2522
|
-
const scoreEmoji = overallScore >= 80 ? '🟢' : overallScore >= 50 ? '🟡' : '🔴';
|
|
2523
|
-
|
|
2524
|
-
return [
|
|
2525
|
-
`## 📊 Brain Coverage Report`,
|
|
2526
|
-
``,
|
|
2527
|
-
`${scoreEmoji} **Overall score: ${overallScore}/100**`,
|
|
2528
|
-
``,
|
|
2529
|
-
`| Metric | Value |`,
|
|
2530
|
-
`|--------|-------|`,
|
|
2531
|
-
`| Total lessons | **${lessonKeys.length}** (${successCount} success · ${failureCount} failure) |`,
|
|
2532
|
-
`| Problem domains | **${domainSet.size}** |`,
|
|
2533
|
-
`| Attribution | **${attributedCount}/${lessonKeys.length}** (${attributionPct}%) |`,
|
|
2534
|
-
`| Contributors | **${personKeys.length}** tracked in knowledge graph |`,
|
|
2535
|
-
`| File coverage | **${fileCoverage}** |`,
|
|
2536
|
-
``,
|
|
2537
|
-
`**Score breakdown (each 0–25):**`,
|
|
2538
|
-
`- Lesson volume: **${Math.round(Math.min(1, lessonKeys.length / 50) * 25)}/25** _(target: 50+ lessons)_`,
|
|
2539
|
-
`- Success ratio: **${Math.round(Math.min(1, successCount / Math.max(1, lessonKeys.length)) * 25)}/25** _(target: all lessons resolved)_`,
|
|
2540
|
-
`- Attribution: **${Math.round(Math.min(1, attributionPct / 100) * 25)}/25** _(target: 100% attributed)_`,
|
|
2541
|
-
`- Team engagement: **${Math.round(Math.min(1, personKeys.length / 5) * 25)}/25** _(target: 5+ contributors)_`,
|
|
2542
|
-
``,
|
|
2543
|
-
`---`,
|
|
2544
|
-
`_Boost score: run \`brain_from_git\` · add \`author\` to \`learn_from_attempts\` · fix gaps with \`skill_gaps\`_`,
|
|
2545
|
-
].join('\n');
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
// ── brain_metrics ─────────────────────────────────────────────────────────
|
|
2549
|
-
// The three decisive metrics (PROGRESS.md §3 / VISION_10X.md §4):
|
|
2550
|
-
// 1. Time-to-first-recall — onboarding friction
|
|
2551
|
-
// 2. Recall-lift — the moat proof (vs. raw BM25)
|
|
2552
|
-
// 3. Team-knowledge-reuse — the value only a shared brain delivers
|
|
2553
|
-
case 'brain_metrics': {
|
|
2554
|
-
const { instance_id } = args as { instance_id: string };
|
|
2555
|
-
const redis = await getConnection(instance_id);
|
|
2556
|
-
|
|
2557
|
-
const [bornAt, firstRecallAt, recallsTotalRaw, crossAuthorRaw, timeSavedRaw, reusePairs] =
|
|
2558
|
-
await Promise.all([
|
|
2559
|
-
redis.get(`cachly:stats:born_at:${instance_id}`).catch(() => null),
|
|
2560
|
-
redis.get(`cachly:stats:first_recall_at:${instance_id}`).catch(() => null),
|
|
2561
|
-
redis.get(`cachly:stats:recalls_total:${instance_id}`).catch(() => null),
|
|
2562
|
-
redis.get(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => null),
|
|
2563
|
-
redis.get(`cachly:stats:time_saved_mins:${instance_id}`).catch(() => null),
|
|
2564
|
-
redis.smembers(`cachly:stats:reuse_pairs:${instance_id}`).catch(() => [] as string[]),
|
|
2565
|
-
]);
|
|
2566
|
-
|
|
2567
|
-
// ── Metric 1: Time-to-first-recall ──────────────────────────────────────
|
|
2568
|
-
let ttfrLine: string;
|
|
2569
|
-
if (bornAt && firstRecallAt) {
|
|
2570
|
-
const ms = new Date(firstRecallAt).getTime() - new Date(bornAt).getTime();
|
|
2571
|
-
const mins = Math.max(0, Math.round(ms / 60000));
|
|
2572
|
-
const human = mins < 1 ? '<1 min' : mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
|
|
2573
|
-
const target = ms <= 2 * 60000 ? '🟢 under 2 min target' : ms <= 60 * 60000 ? '🟡 over 2 min' : '🔴 over 1 h';
|
|
2574
|
-
ttfrLine = `**${human}** from first lesson → first proven recall · ${target}`;
|
|
2575
|
-
} else if (bornAt && !firstRecallAt) {
|
|
2576
|
-
ttfrLine = `🟡 Brain has knowledge but no proven recall yet — try \`smart_recall\``;
|
|
2577
|
-
} else {
|
|
2578
|
-
ttfrLine = `⚪ Not enough data yet — store a lesson, then recall it`;
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
// ── Metric 2: Recall-lift (the moat proof) ──────────────────────────────
|
|
2582
|
-
// Published headline from Cachly-Bench (CI-defended in rerank.test.ts).
|
|
2583
|
-
const recallLiftLine = `**+22.2 % Precision@1**, **+10.9 % MRR**, **+8.1 % nDCG@5** vs. raw BM25`;
|
|
2584
|
-
|
|
2585
|
-
// ── Metric 3: Team-knowledge-reuse ──────────────────────────────────────
|
|
2586
|
-
const recallsTotal = Number(recallsTotalRaw ?? 0);
|
|
2587
|
-
const crossAuthor = Number(crossAuthorRaw ?? 0);
|
|
2588
|
-
const reusePct = recallsTotal > 0 ? Math.round((crossAuthor / recallsTotal) * 100) : 0;
|
|
2589
|
-
const reuseTarget = reusePct >= 30 ? '🟢 above 30 % target' : recallsTotal === 0 ? '⚪ no recalls yet' : '🟡 below 30 % target';
|
|
2590
|
-
const timeSaved = Math.round(Number(timeSavedRaw ?? 0));
|
|
2591
|
-
const timeSavedHuman = timeSaved < 60 ? `${timeSaved} min` : `${(timeSaved / 60).toFixed(1)} h`;
|
|
2592
|
-
|
|
2593
|
-
const lines = [
|
|
2594
|
-
`## 📈 Brain Metrics — the three that decide everything`,
|
|
2595
|
-
``,
|
|
2596
|
-
`### 1. Time-to-first-recall _(onboarding friction)_`,
|
|
2597
|
-
ttfrLine,
|
|
2598
|
-
``,
|
|
2599
|
-
`### 2. Recall-lift _(the moat proof)_`,
|
|
2600
|
-
recallLiftLine,
|
|
2601
|
-
`_Reproduce: \`npm run bench\` · CI-defended in \`rerank.test.ts\` · details in BENCH.md_`,
|
|
2602
|
-
``,
|
|
2603
|
-
`### 3. Team-knowledge-reuse _(the value only a shared brain delivers)_`,
|
|
2604
|
-
`**${reusePct}%** of proven recalls used a teammate's lesson (${crossAuthor}/${recallsTotal}) · ${reuseTarget}`,
|
|
2605
|
-
reusePairs.length > 0 ? `**${reusePairs.length}** distinct reuse relationship${reusePairs.length !== 1 ? 's' : ''} across the team` : '',
|
|
2606
|
-
``,
|
|
2607
|
-
`---`,
|
|
2608
|
-
`⏱️ **${timeSavedHuman}** total saved not re-researching known fixes.`,
|
|
2609
|
-
recallsTotal === 0
|
|
2610
|
-
? `_Tip: pass \`author="your-handle"\` to \`smart_recall\` so cross-author reuse can be tracked._`
|
|
2611
|
-
: `_These numbers compound: every learned lesson and every teammate raises all three._`,
|
|
2612
|
-
].filter(Boolean);
|
|
2613
|
-
|
|
2614
|
-
return lines.join('\n');
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
// ── sync_file_changes ─────────────────────────────────────────────────────
|
|
2618
|
-
|
|
2619
|
-
default:
|
|
2620
|
-
return null;
|
|
2621
|
-
}
|
|
2622
|
-
}
|