feed-the-machine 1.6.0 → 1.7.0
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/LICENSE +21 -21
- package/README.md +170 -170
- package/bin/brain.py +1340 -0
- package/bin/convert_claude_skills_to_codex.py +490 -0
- package/bin/generate-manifest.mjs +463 -463
- package/bin/harden_codex_skills.py +141 -0
- package/bin/install.mjs +491 -491
- package/bin/migrate-eng-buddy-data.py +875 -0
- package/bin/playbook_engine/__init__.py +1 -0
- package/bin/playbook_engine/conftest.py +8 -0
- package/bin/playbook_engine/extractor.py +33 -0
- package/bin/playbook_engine/manager.py +102 -0
- package/bin/playbook_engine/models.py +84 -0
- package/bin/playbook_engine/registry.py +35 -0
- package/bin/playbook_engine/test_extractor.py +72 -0
- package/bin/playbook_engine/test_integration.py +129 -0
- package/bin/playbook_engine/test_manager.py +85 -0
- package/bin/playbook_engine/test_models.py +166 -0
- package/bin/playbook_engine/test_registry.py +67 -0
- package/bin/playbook_engine/test_tracer.py +86 -0
- package/bin/playbook_engine/tracer.py +93 -0
- package/bin/tasks_db.py +456 -0
- package/docs/HOOKS.md +243 -243
- package/docs/INBOX.md +233 -233
- package/ftm/SKILL.md +125 -122
- package/ftm-audit/SKILL.md +623 -623
- package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
- package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
- package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
- package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
- package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
- package/ftm-audit/scripts/run-knip.sh +23 -23
- package/ftm-audit.yml +2 -2
- package/ftm-brainstorm/SKILL.md +1003 -498
- package/ftm-brainstorm/evals/evals.json +180 -100
- package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
- package/ftm-brainstorm/references/agent-prompts.md +552 -224
- package/ftm-brainstorm/references/plan-template.md +209 -121
- package/ftm-brainstorm.yml +2 -2
- package/ftm-browse/SKILL.md +454 -454
- package/ftm-browse/daemon/browser-manager.ts +206 -206
- package/ftm-browse/daemon/bun.lock +30 -30
- package/ftm-browse/daemon/cli.ts +347 -347
- package/ftm-browse/daemon/commands.ts +410 -410
- package/ftm-browse/daemon/main.ts +357 -357
- package/ftm-browse/daemon/package.json +17 -17
- package/ftm-browse/daemon/server.ts +189 -189
- package/ftm-browse/daemon/snapshot.ts +519 -519
- package/ftm-browse/daemon/tsconfig.json +22 -22
- package/ftm-browse.yml +4 -4
- package/ftm-capture/SKILL.md +370 -370
- package/ftm-capture.yml +4 -4
- package/ftm-codex-gate/SKILL.md +361 -361
- package/ftm-codex-gate.yml +2 -2
- package/ftm-config/SKILL.md +422 -345
- package/ftm-config.default.yml +125 -82
- package/ftm-config.yml +44 -2
- package/ftm-council/SKILL.md +416 -416
- package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
- package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
- package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
- package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
- package/ftm-council.yml +2 -2
- package/ftm-dashboard/SKILL.md +163 -163
- package/ftm-dashboard.yml +4 -4
- package/ftm-debug/SKILL.md +1037 -1037
- package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
- package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
- package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
- package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
- package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
- package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
- package/ftm-debug.yml +2 -2
- package/ftm-diagram/SKILL.md +277 -277
- package/ftm-diagram.yml +2 -2
- package/ftm-executor/SKILL.md +777 -777
- package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
- package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
- package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
- package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
- package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
- package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
- package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
- package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
- package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
- package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
- package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
- package/ftm-executor/runtime/package.json +8 -8
- package/ftm-executor.yml +2 -2
- package/ftm-git/SKILL.md +441 -441
- package/ftm-git/evals/evals.json +26 -26
- package/ftm-git/evals/promptfoo.yaml +75 -75
- package/ftm-git/hooks/post-commit-experience.sh +92 -92
- package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
- package/ftm-git/references/protocols/REMEDIATION.md +139 -139
- package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
- package/ftm-git.yml +2 -2
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -64
- package/ftm-inbox/backend/adapters/base.py +230 -230
- package/ftm-inbox/backend/adapters/freshservice.py +104 -104
- package/ftm-inbox/backend/adapters/gmail.py +125 -125
- package/ftm-inbox/backend/adapters/jira.py +136 -136
- package/ftm-inbox/backend/adapters/registry.py +192 -192
- package/ftm-inbox/backend/adapters/slack.py +110 -110
- package/ftm-inbox/backend/db/connection.py +54 -54
- package/ftm-inbox/backend/db/schema.py +78 -78
- package/ftm-inbox/backend/executor/__init__.py +7 -7
- package/ftm-inbox/backend/executor/engine.py +149 -149
- package/ftm-inbox/backend/executor/step_runner.py +98 -98
- package/ftm-inbox/backend/main.py +103 -103
- package/ftm-inbox/backend/models/__init__.py +1 -1
- package/ftm-inbox/backend/models/unified_task.py +36 -36
- package/ftm-inbox/backend/planner/__init__.py +6 -6
- package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/generator.py +127 -127
- package/ftm-inbox/backend/planner/schema.py +34 -34
- package/ftm-inbox/backend/requirements.txt +5 -5
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -186
- package/ftm-inbox/backend/routes/health.py +52 -52
- package/ftm-inbox/backend/routes/inbox.py +68 -68
- package/ftm-inbox/backend/routes/plan.py +271 -271
- package/ftm-inbox/bin/launchagent.mjs +91 -91
- package/ftm-inbox/bin/setup.mjs +188 -188
- package/ftm-inbox/bin/start.sh +10 -10
- package/ftm-inbox/bin/status.sh +17 -17
- package/ftm-inbox/bin/stop.sh +8 -8
- package/ftm-inbox/config.example.yml +55 -55
- package/ftm-inbox/package-lock.json +2898 -2898
- package/ftm-inbox/package.json +26 -26
- package/ftm-inbox/postcss.config.js +6 -6
- package/ftm-inbox/src/app.css +199 -199
- package/ftm-inbox/src/app.html +18 -18
- package/ftm-inbox/src/lib/api.ts +166 -166
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
- package/ftm-inbox/src/lib/theme.ts +47 -47
- package/ftm-inbox/src/routes/+layout.svelte +76 -76
- package/ftm-inbox/src/routes/+page.svelte +401 -401
- package/ftm-inbox/svelte.config.js +12 -12
- package/ftm-inbox/tailwind.config.ts +63 -63
- package/ftm-inbox/tsconfig.json +13 -13
- package/ftm-inbox/vite.config.ts +6 -6
- package/ftm-intent/SKILL.md +241 -241
- package/ftm-intent.yml +2 -2
- package/ftm-manifest.json +3794 -3794
- package/ftm-map/SKILL.md +291 -291
- package/ftm-map/scripts/db.py +712 -712
- package/ftm-map/scripts/index.py +415 -415
- package/ftm-map/scripts/parser.py +224 -224
- package/ftm-map/scripts/queries/go-tags.scm +20 -20
- package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
- package/ftm-map/scripts/queries/python-tags.scm +31 -31
- package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
- package/ftm-map/scripts/queries/rust-tags.scm +37 -37
- package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
- package/ftm-map/scripts/query.py +301 -301
- package/ftm-map/scripts/ranker.py +377 -377
- package/ftm-map/scripts/requirements.txt +5 -5
- package/ftm-map/scripts/setup-hooks.sh +27 -27
- package/ftm-map/scripts/setup.sh +56 -56
- package/ftm-map/scripts/test_db.py +364 -364
- package/ftm-map/scripts/test_parser.py +174 -174
- package/ftm-map/scripts/test_query.py +183 -183
- package/ftm-map/scripts/test_ranker.py +199 -199
- package/ftm-map/scripts/views.py +591 -591
- package/ftm-map.yml +2 -2
- package/ftm-mind/SKILL.md +201 -1943
- package/ftm-mind/evals/promptfoo.yaml +142 -142
- package/ftm-mind/references/blackboard-protocol.md +110 -0
- package/ftm-mind/references/blackboard-schema.md +328 -328
- package/ftm-mind/references/complexity-guide.md +110 -110
- package/ftm-mind/references/complexity-sizing.md +138 -0
- package/ftm-mind/references/decide-act-protocol.md +172 -0
- package/ftm-mind/references/direct-execution.md +51 -0
- package/ftm-mind/references/environment-discovery.md +77 -0
- package/ftm-mind/references/event-registry.md +319 -319
- package/ftm-mind/references/mcp-inventory.md +300 -296
- package/ftm-mind/references/ops-routing.md +47 -0
- package/ftm-mind/references/orient-protocol.md +234 -0
- package/ftm-mind/references/personality.md +40 -0
- package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
- package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
- package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
- package/ftm-mind/references/reflexion-protocol.md +249 -249
- package/ftm-mind/references/routing/SCENARIOS.md +22 -22
- package/ftm-mind/references/routing-scenarios.md +35 -35
- package/ftm-mind.yml +2 -2
- package/ftm-ops.yml +4 -0
- package/ftm-pause/SKILL.md +395 -395
- package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
- package/ftm-pause/references/protocols/VALIDATION.md +80 -80
- package/ftm-pause.yml +2 -2
- package/ftm-researcher/SKILL.md +275 -275
- package/ftm-researcher/evals/agent-diversity.yaml +17 -17
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
- package/ftm-researcher/references/adaptive-search.md +116 -116
- package/ftm-researcher/references/agent-prompts.md +193 -193
- package/ftm-researcher/references/council-integration.md +193 -193
- package/ftm-researcher/references/output-format.md +203 -203
- package/ftm-researcher/references/synthesis-pipeline.md +165 -165
- package/ftm-researcher/scripts/score_credibility.py +234 -234
- package/ftm-researcher/scripts/validate_research.py +92 -92
- package/ftm-researcher.yml +2 -2
- package/ftm-resume/SKILL.md +518 -518
- package/ftm-resume/references/protocols/VALIDATION.md +172 -172
- package/ftm-resume.yml +2 -2
- package/ftm-retro/SKILL.md +380 -380
- package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
- package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
- package/ftm-retro.yml +2 -2
- package/ftm-routine/SKILL.md +170 -170
- package/ftm-routine.yml +4 -4
- package/ftm-state/blackboard/capabilities.json +5 -5
- package/ftm-state/blackboard/capabilities.schema.json +27 -27
- package/ftm-state/blackboard/context.json +37 -23
- package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
- package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
- package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
- package/ftm-state/blackboard/experiences/index.json +58 -9
- package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
- package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
- package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
- package/ftm-state/blackboard/patterns.json +6 -6
- package/ftm-state/schemas/context.schema.json +130 -130
- package/ftm-state/schemas/experience-index.schema.json +77 -77
- package/ftm-state/schemas/experience.schema.json +78 -78
- package/ftm-state/schemas/patterns.schema.json +44 -44
- package/ftm-upgrade/SKILL.md +194 -194
- package/ftm-upgrade/scripts/check-version.sh +76 -76
- package/ftm-upgrade/scripts/upgrade.sh +143 -143
- package/ftm-upgrade.yml +2 -2
- package/ftm-verify.yml +2 -2
- package/ftm.yml +2 -2
- package/hooks/ftm-auto-log.sh +137 -0
- package/hooks/ftm-blackboard-enforcer.sh +93 -93
- package/hooks/ftm-discovery-reminder.sh +90 -90
- package/hooks/ftm-drafts-gate.sh +61 -61
- package/hooks/ftm-event-logger.mjs +107 -107
- package/hooks/ftm-install-hooks.sh +240 -0
- package/hooks/ftm-learning-capture.sh +117 -0
- package/hooks/ftm-map-autodetect.sh +79 -79
- package/hooks/ftm-pending-sync-check.sh +22 -22
- package/hooks/ftm-plan-gate.sh +92 -92
- package/hooks/ftm-post-commit-trigger.sh +57 -57
- package/hooks/ftm-post-compaction.sh +138 -0
- package/hooks/ftm-pre-compaction.sh +147 -0
- package/hooks/ftm-session-end.sh +52 -0
- package/hooks/ftm-session-snapshot.sh +213 -0
- package/hooks/settings-template.json +81 -81
- package/install.sh +363 -363
- package/package.json +84 -84
- package/uninstall.sh +25 -25
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
"""
|
|
2
|
-
AdapterRegistry — loads and manages poller adapters from config.yml.
|
|
3
|
-
|
|
4
|
-
Config format (see config.example.yml):
|
|
5
|
-
|
|
6
|
-
adapters:
|
|
7
|
-
- name: jira
|
|
8
|
-
class: ftm_inbox.adapters.jira.JiraAdapter
|
|
9
|
-
interval_seconds: 60
|
|
10
|
-
credentials:
|
|
11
|
-
api_token: "..."
|
|
12
|
-
email: "..."
|
|
13
|
-
config:
|
|
14
|
-
base_url: "https://myorg.atlassian.net"
|
|
15
|
-
project_key: "OPS"
|
|
16
|
-
|
|
17
|
-
The registry uses importlib to load adapter classes by dotted module path,
|
|
18
|
-
validates that each class is a subclass of BaseAdapter, and confirms that
|
|
19
|
-
credentials are present (non-empty) before registering the adapter.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
import importlib
|
|
23
|
-
import logging
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
from typing import Any
|
|
26
|
-
|
|
27
|
-
import yaml
|
|
28
|
-
|
|
29
|
-
from .base import BaseAdapter
|
|
30
|
-
|
|
31
|
-
logger = logging.getLogger(__name__)
|
|
32
|
-
|
|
33
|
-
_DEFAULT_CONFIG_PATH = (
|
|
34
|
-
Path(__file__).resolve().parent.parent.parent / "config.yml"
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class AdapterRegistryError(Exception):
|
|
39
|
-
"""Raised when an adapter cannot be loaded or validated."""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class AdapterRegistry:
|
|
43
|
-
"""
|
|
44
|
-
Loads adapter definitions from config.yml and instantiates each one.
|
|
45
|
-
|
|
46
|
-
Usage:
|
|
47
|
-
registry = AdapterRegistry.from_config()
|
|
48
|
-
for adapter in registry.adapters:
|
|
49
|
-
adapter.run_cycle(conn)
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
def __init__(self) -> None:
|
|
53
|
-
self._adapters: list[BaseAdapter] = []
|
|
54
|
-
|
|
55
|
-
@property
|
|
56
|
-
def adapters(self) -> list[BaseAdapter]:
|
|
57
|
-
return list(self._adapters)
|
|
58
|
-
|
|
59
|
-
@classmethod
|
|
60
|
-
def from_config(
|
|
61
|
-
cls, config_path: Path | None = None
|
|
62
|
-
) -> "AdapterRegistry":
|
|
63
|
-
"""
|
|
64
|
-
Build a registry from a YAML config file.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
config_path: Path to config.yml. Defaults to <project_root>/config.yml.
|
|
68
|
-
|
|
69
|
-
Returns:
|
|
70
|
-
Populated AdapterRegistry.
|
|
71
|
-
|
|
72
|
-
Raises:
|
|
73
|
-
AdapterRegistryError: If the config file is missing or an adapter
|
|
74
|
-
fails validation.
|
|
75
|
-
"""
|
|
76
|
-
path = config_path or _DEFAULT_CONFIG_PATH
|
|
77
|
-
|
|
78
|
-
if not path.exists():
|
|
79
|
-
logger.warning(
|
|
80
|
-
"config.yml not found at %s — no adapters registered.", path
|
|
81
|
-
)
|
|
82
|
-
return cls()
|
|
83
|
-
|
|
84
|
-
with path.open() as fh:
|
|
85
|
-
raw = yaml.safe_load(fh) or {}
|
|
86
|
-
|
|
87
|
-
adapter_defs: list[dict[str, Any]] = raw.get("adapters", [])
|
|
88
|
-
registry = cls()
|
|
89
|
-
|
|
90
|
-
for definition in adapter_defs:
|
|
91
|
-
try:
|
|
92
|
-
adapter = _load_adapter(definition)
|
|
93
|
-
registry._adapters.append(adapter)
|
|
94
|
-
logger.info(
|
|
95
|
-
"Registered adapter '%s' (%s)",
|
|
96
|
-
definition.get("name"),
|
|
97
|
-
definition.get("class"),
|
|
98
|
-
)
|
|
99
|
-
except AdapterRegistryError as exc:
|
|
100
|
-
logger.error("Skipping adapter '%s': %s", definition.get("name"), exc)
|
|
101
|
-
|
|
102
|
-
return registry
|
|
103
|
-
|
|
104
|
-
def get(self, name: str) -> BaseAdapter | None:
|
|
105
|
-
"""Return a registered adapter by name, or None."""
|
|
106
|
-
for adapter in self._adapters:
|
|
107
|
-
if adapter.source_name == name:
|
|
108
|
-
return adapter
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
def __len__(self) -> int:
|
|
112
|
-
return len(self._adapters)
|
|
113
|
-
|
|
114
|
-
def __repr__(self) -> str:
|
|
115
|
-
names = [a.source_name for a in self._adapters]
|
|
116
|
-
return f"AdapterRegistry(adapters={names})"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _load_adapter(definition: dict[str, Any]) -> BaseAdapter:
|
|
120
|
-
"""
|
|
121
|
-
Instantiate a single adapter from its config definition.
|
|
122
|
-
|
|
123
|
-
Validates:
|
|
124
|
-
- 'class' key is present and is a valid dotted path
|
|
125
|
-
- The resolved class is a subclass of BaseAdapter
|
|
126
|
-
- Required credentials keys are present and non-empty
|
|
127
|
-
|
|
128
|
-
Raises:
|
|
129
|
-
AdapterRegistryError on any validation failure.
|
|
130
|
-
"""
|
|
131
|
-
dotted_path: str = definition.get("class", "")
|
|
132
|
-
if not dotted_path:
|
|
133
|
-
raise AdapterRegistryError("Missing 'class' key in adapter definition.")
|
|
134
|
-
|
|
135
|
-
module_path, _, class_name = dotted_path.rpartition(".")
|
|
136
|
-
if not module_path:
|
|
137
|
-
raise AdapterRegistryError(
|
|
138
|
-
f"'class' must be a dotted path (got '{dotted_path}')."
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
try:
|
|
142
|
-
module = importlib.import_module(module_path)
|
|
143
|
-
except ImportError as exc:
|
|
144
|
-
raise AdapterRegistryError(
|
|
145
|
-
f"Cannot import module '{module_path}': {exc}"
|
|
146
|
-
) from exc
|
|
147
|
-
|
|
148
|
-
klass = getattr(module, class_name, None)
|
|
149
|
-
if klass is None:
|
|
150
|
-
raise AdapterRegistryError(
|
|
151
|
-
f"Class '{class_name}' not found in module '{module_path}'."
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
if not (isinstance(klass, type) and issubclass(klass, BaseAdapter)):
|
|
155
|
-
raise AdapterRegistryError(
|
|
156
|
-
f"'{dotted_path}' is not a subclass of BaseAdapter."
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
credentials: dict[str, Any] = definition.get("credentials", {}) or {}
|
|
160
|
-
config: dict[str, Any] = definition.get("config", {}) or {}
|
|
161
|
-
|
|
162
|
-
_validate_credentials(definition.get("name", dotted_path), credentials, klass)
|
|
163
|
-
|
|
164
|
-
instance = klass(credentials=credentials, config=config)
|
|
165
|
-
# Allow the class to declare its source_name via class attribute;
|
|
166
|
-
# fall back to the 'name' key in the config definition.
|
|
167
|
-
if not instance.source_name:
|
|
168
|
-
instance.source_name = definition.get("name", class_name.lower())
|
|
169
|
-
|
|
170
|
-
return instance
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _validate_credentials(
|
|
174
|
-
adapter_name: str,
|
|
175
|
-
credentials: dict[str, Any],
|
|
176
|
-
klass: type,
|
|
177
|
-
) -> None:
|
|
178
|
-
"""
|
|
179
|
-
Check that all keys declared in klass.required_credentials are present
|
|
180
|
-
and non-empty in the credentials dict.
|
|
181
|
-
|
|
182
|
-
Adapters opt into this by setting a class-level list:
|
|
183
|
-
required_credentials = ["api_token", "email"]
|
|
184
|
-
|
|
185
|
-
If the class doesn't declare required_credentials, skip validation.
|
|
186
|
-
"""
|
|
187
|
-
required: list[str] = getattr(klass, "required_credentials", [])
|
|
188
|
-
missing = [key for key in required if not credentials.get(key)]
|
|
189
|
-
if missing:
|
|
190
|
-
raise AdapterRegistryError(
|
|
191
|
-
f"Adapter '{adapter_name}' missing required credentials: {missing}"
|
|
192
|
-
)
|
|
1
|
+
"""
|
|
2
|
+
AdapterRegistry — loads and manages poller adapters from config.yml.
|
|
3
|
+
|
|
4
|
+
Config format (see config.example.yml):
|
|
5
|
+
|
|
6
|
+
adapters:
|
|
7
|
+
- name: jira
|
|
8
|
+
class: ftm_inbox.adapters.jira.JiraAdapter
|
|
9
|
+
interval_seconds: 60
|
|
10
|
+
credentials:
|
|
11
|
+
api_token: "..."
|
|
12
|
+
email: "..."
|
|
13
|
+
config:
|
|
14
|
+
base_url: "https://myorg.atlassian.net"
|
|
15
|
+
project_key: "OPS"
|
|
16
|
+
|
|
17
|
+
The registry uses importlib to load adapter classes by dotted module path,
|
|
18
|
+
validates that each class is a subclass of BaseAdapter, and confirms that
|
|
19
|
+
credentials are present (non-empty) before registering the adapter.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from .base import BaseAdapter
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_DEFAULT_CONFIG_PATH = (
|
|
34
|
+
Path(__file__).resolve().parent.parent.parent / "config.yml"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AdapterRegistryError(Exception):
|
|
39
|
+
"""Raised when an adapter cannot be loaded or validated."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AdapterRegistry:
|
|
43
|
+
"""
|
|
44
|
+
Loads adapter definitions from config.yml and instantiates each one.
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
registry = AdapterRegistry.from_config()
|
|
48
|
+
for adapter in registry.adapters:
|
|
49
|
+
adapter.run_cycle(conn)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._adapters: list[BaseAdapter] = []
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def adapters(self) -> list[BaseAdapter]:
|
|
57
|
+
return list(self._adapters)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_config(
|
|
61
|
+
cls, config_path: Path | None = None
|
|
62
|
+
) -> "AdapterRegistry":
|
|
63
|
+
"""
|
|
64
|
+
Build a registry from a YAML config file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_path: Path to config.yml. Defaults to <project_root>/config.yml.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Populated AdapterRegistry.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
AdapterRegistryError: If the config file is missing or an adapter
|
|
74
|
+
fails validation.
|
|
75
|
+
"""
|
|
76
|
+
path = config_path or _DEFAULT_CONFIG_PATH
|
|
77
|
+
|
|
78
|
+
if not path.exists():
|
|
79
|
+
logger.warning(
|
|
80
|
+
"config.yml not found at %s — no adapters registered.", path
|
|
81
|
+
)
|
|
82
|
+
return cls()
|
|
83
|
+
|
|
84
|
+
with path.open() as fh:
|
|
85
|
+
raw = yaml.safe_load(fh) or {}
|
|
86
|
+
|
|
87
|
+
adapter_defs: list[dict[str, Any]] = raw.get("adapters", [])
|
|
88
|
+
registry = cls()
|
|
89
|
+
|
|
90
|
+
for definition in adapter_defs:
|
|
91
|
+
try:
|
|
92
|
+
adapter = _load_adapter(definition)
|
|
93
|
+
registry._adapters.append(adapter)
|
|
94
|
+
logger.info(
|
|
95
|
+
"Registered adapter '%s' (%s)",
|
|
96
|
+
definition.get("name"),
|
|
97
|
+
definition.get("class"),
|
|
98
|
+
)
|
|
99
|
+
except AdapterRegistryError as exc:
|
|
100
|
+
logger.error("Skipping adapter '%s': %s", definition.get("name"), exc)
|
|
101
|
+
|
|
102
|
+
return registry
|
|
103
|
+
|
|
104
|
+
def get(self, name: str) -> BaseAdapter | None:
|
|
105
|
+
"""Return a registered adapter by name, or None."""
|
|
106
|
+
for adapter in self._adapters:
|
|
107
|
+
if adapter.source_name == name:
|
|
108
|
+
return adapter
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def __len__(self) -> int:
|
|
112
|
+
return len(self._adapters)
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
names = [a.source_name for a in self._adapters]
|
|
116
|
+
return f"AdapterRegistry(adapters={names})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_adapter(definition: dict[str, Any]) -> BaseAdapter:
|
|
120
|
+
"""
|
|
121
|
+
Instantiate a single adapter from its config definition.
|
|
122
|
+
|
|
123
|
+
Validates:
|
|
124
|
+
- 'class' key is present and is a valid dotted path
|
|
125
|
+
- The resolved class is a subclass of BaseAdapter
|
|
126
|
+
- Required credentials keys are present and non-empty
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
AdapterRegistryError on any validation failure.
|
|
130
|
+
"""
|
|
131
|
+
dotted_path: str = definition.get("class", "")
|
|
132
|
+
if not dotted_path:
|
|
133
|
+
raise AdapterRegistryError("Missing 'class' key in adapter definition.")
|
|
134
|
+
|
|
135
|
+
module_path, _, class_name = dotted_path.rpartition(".")
|
|
136
|
+
if not module_path:
|
|
137
|
+
raise AdapterRegistryError(
|
|
138
|
+
f"'class' must be a dotted path (got '{dotted_path}')."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
module = importlib.import_module(module_path)
|
|
143
|
+
except ImportError as exc:
|
|
144
|
+
raise AdapterRegistryError(
|
|
145
|
+
f"Cannot import module '{module_path}': {exc}"
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
klass = getattr(module, class_name, None)
|
|
149
|
+
if klass is None:
|
|
150
|
+
raise AdapterRegistryError(
|
|
151
|
+
f"Class '{class_name}' not found in module '{module_path}'."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not (isinstance(klass, type) and issubclass(klass, BaseAdapter)):
|
|
155
|
+
raise AdapterRegistryError(
|
|
156
|
+
f"'{dotted_path}' is not a subclass of BaseAdapter."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
credentials: dict[str, Any] = definition.get("credentials", {}) or {}
|
|
160
|
+
config: dict[str, Any] = definition.get("config", {}) or {}
|
|
161
|
+
|
|
162
|
+
_validate_credentials(definition.get("name", dotted_path), credentials, klass)
|
|
163
|
+
|
|
164
|
+
instance = klass(credentials=credentials, config=config)
|
|
165
|
+
# Allow the class to declare its source_name via class attribute;
|
|
166
|
+
# fall back to the 'name' key in the config definition.
|
|
167
|
+
if not instance.source_name:
|
|
168
|
+
instance.source_name = definition.get("name", class_name.lower())
|
|
169
|
+
|
|
170
|
+
return instance
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_credentials(
|
|
174
|
+
adapter_name: str,
|
|
175
|
+
credentials: dict[str, Any],
|
|
176
|
+
klass: type,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Check that all keys declared in klass.required_credentials are present
|
|
180
|
+
and non-empty in the credentials dict.
|
|
181
|
+
|
|
182
|
+
Adapters opt into this by setting a class-level list:
|
|
183
|
+
required_credentials = ["api_token", "email"]
|
|
184
|
+
|
|
185
|
+
If the class doesn't declare required_credentials, skip validation.
|
|
186
|
+
"""
|
|
187
|
+
required: list[str] = getattr(klass, "required_credentials", [])
|
|
188
|
+
missing = [key for key in required if not credentials.get(key)]
|
|
189
|
+
if missing:
|
|
190
|
+
raise AdapterRegistryError(
|
|
191
|
+
f"Adapter '{adapter_name}' missing required credentials: {missing}"
|
|
192
|
+
)
|
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
"""
|
|
2
|
-
SlackAdapter — polls Slack channels for recent messages.
|
|
3
|
-
|
|
4
|
-
Required credentials:
|
|
5
|
-
bot_token Slack bot token (xoxb-...)
|
|
6
|
-
|
|
7
|
-
Config keys:
|
|
8
|
-
channels List of channel IDs to poll
|
|
9
|
-
lookback_hours How far back to fetch messages, default 24
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import logging
|
|
15
|
-
import time
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
18
|
-
import requests
|
|
19
|
-
|
|
20
|
-
from backend.adapters._retry import retry
|
|
21
|
-
from backend.adapters.base import BaseAdapter, NormalizedItem
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class SlackAdapter(BaseAdapter):
|
|
27
|
-
"""Polls Slack channels for recent messages."""
|
|
28
|
-
|
|
29
|
-
source_name = "slack"
|
|
30
|
-
required_credentials = ["bot_token"]
|
|
31
|
-
|
|
32
|
-
def __init__(self, credentials: dict, config: dict) -> None:
|
|
33
|
-
super().__init__(credentials, config)
|
|
34
|
-
self._token = credentials["bot_token"]
|
|
35
|
-
self._channels: list[str] = config.get("channels", [])
|
|
36
|
-
self._lookback_hours = int(config.get("lookback_hours", 24))
|
|
37
|
-
self._headers = {
|
|
38
|
-
"Authorization": f"Bearer {self._token}",
|
|
39
|
-
"Content-Type": "application/json",
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
@retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
|
|
43
|
-
def poll(self) -> list[dict]:
|
|
44
|
-
"""Fetch recent messages from configured Slack channels."""
|
|
45
|
-
oldest = str(time.time() - self._lookback_hours * 3600)
|
|
46
|
-
all_messages: list[dict] = []
|
|
47
|
-
|
|
48
|
-
for channel_id in self._channels:
|
|
49
|
-
url = "https://slack.com/api/conversations.history"
|
|
50
|
-
params: dict[str, Any] = {
|
|
51
|
-
"channel": channel_id,
|
|
52
|
-
"oldest": oldest,
|
|
53
|
-
"limit": 200,
|
|
54
|
-
}
|
|
55
|
-
response = requests.get(
|
|
56
|
-
url, headers=self._headers, params=params, timeout=30
|
|
57
|
-
)
|
|
58
|
-
response.raise_for_status()
|
|
59
|
-
data = response.json()
|
|
60
|
-
|
|
61
|
-
if not data.get("ok"):
|
|
62
|
-
logger.warning(
|
|
63
|
-
"Slack API error for channel %s: %s",
|
|
64
|
-
channel_id,
|
|
65
|
-
data.get("error", "unknown"),
|
|
66
|
-
)
|
|
67
|
-
continue
|
|
68
|
-
|
|
69
|
-
messages = data.get("messages", [])
|
|
70
|
-
for msg in messages:
|
|
71
|
-
msg["_channel_id"] = channel_id
|
|
72
|
-
all_messages.extend(messages)
|
|
73
|
-
|
|
74
|
-
return all_messages
|
|
75
|
-
|
|
76
|
-
def normalize(self, raw_item: dict) -> NormalizedItem:
|
|
77
|
-
"""Map a Slack message dict to NormalizedItem."""
|
|
78
|
-
ts = raw_item.get("ts", "")
|
|
79
|
-
channel_id = raw_item.get("_channel_id", "")
|
|
80
|
-
text = raw_item.get("text") or ""
|
|
81
|
-
|
|
82
|
-
title = text[:100].replace("\n", " ") if text else "(no text)"
|
|
83
|
-
body = text
|
|
84
|
-
|
|
85
|
-
user = raw_item.get("user") or raw_item.get("bot_id") or "unknown"
|
|
86
|
-
|
|
87
|
-
created_at = ts
|
|
88
|
-
|
|
89
|
-
source_url = (
|
|
90
|
-
f"https://slack.com/archives/{channel_id}/p{ts.replace('.', '')}"
|
|
91
|
-
if channel_id and ts
|
|
92
|
-
else None
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
return NormalizedItem(
|
|
96
|
-
source=self.source_name,
|
|
97
|
-
source_id=f"{channel_id}:{ts}",
|
|
98
|
-
title=title,
|
|
99
|
-
body=body,
|
|
100
|
-
status="open",
|
|
101
|
-
priority="medium",
|
|
102
|
-
assignee=None,
|
|
103
|
-
requester=user,
|
|
104
|
-
created_at=created_at,
|
|
105
|
-
updated_at=None,
|
|
106
|
-
tags=[],
|
|
107
|
-
custom_fields={"channel_id": channel_id},
|
|
108
|
-
raw_payload=raw_item,
|
|
109
|
-
source_url=source_url,
|
|
110
|
-
)
|
|
1
|
+
"""
|
|
2
|
+
SlackAdapter — polls Slack channels for recent messages.
|
|
3
|
+
|
|
4
|
+
Required credentials:
|
|
5
|
+
bot_token Slack bot token (xoxb-...)
|
|
6
|
+
|
|
7
|
+
Config keys:
|
|
8
|
+
channels List of channel IDs to poll
|
|
9
|
+
lookback_hours How far back to fetch messages, default 24
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from backend.adapters._retry import retry
|
|
21
|
+
from backend.adapters.base import BaseAdapter, NormalizedItem
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SlackAdapter(BaseAdapter):
|
|
27
|
+
"""Polls Slack channels for recent messages."""
|
|
28
|
+
|
|
29
|
+
source_name = "slack"
|
|
30
|
+
required_credentials = ["bot_token"]
|
|
31
|
+
|
|
32
|
+
def __init__(self, credentials: dict, config: dict) -> None:
|
|
33
|
+
super().__init__(credentials, config)
|
|
34
|
+
self._token = credentials["bot_token"]
|
|
35
|
+
self._channels: list[str] = config.get("channels", [])
|
|
36
|
+
self._lookback_hours = int(config.get("lookback_hours", 24))
|
|
37
|
+
self._headers = {
|
|
38
|
+
"Authorization": f"Bearer {self._token}",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
|
|
43
|
+
def poll(self) -> list[dict]:
|
|
44
|
+
"""Fetch recent messages from configured Slack channels."""
|
|
45
|
+
oldest = str(time.time() - self._lookback_hours * 3600)
|
|
46
|
+
all_messages: list[dict] = []
|
|
47
|
+
|
|
48
|
+
for channel_id in self._channels:
|
|
49
|
+
url = "https://slack.com/api/conversations.history"
|
|
50
|
+
params: dict[str, Any] = {
|
|
51
|
+
"channel": channel_id,
|
|
52
|
+
"oldest": oldest,
|
|
53
|
+
"limit": 200,
|
|
54
|
+
}
|
|
55
|
+
response = requests.get(
|
|
56
|
+
url, headers=self._headers, params=params, timeout=30
|
|
57
|
+
)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
data = response.json()
|
|
60
|
+
|
|
61
|
+
if not data.get("ok"):
|
|
62
|
+
logger.warning(
|
|
63
|
+
"Slack API error for channel %s: %s",
|
|
64
|
+
channel_id,
|
|
65
|
+
data.get("error", "unknown"),
|
|
66
|
+
)
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
messages = data.get("messages", [])
|
|
70
|
+
for msg in messages:
|
|
71
|
+
msg["_channel_id"] = channel_id
|
|
72
|
+
all_messages.extend(messages)
|
|
73
|
+
|
|
74
|
+
return all_messages
|
|
75
|
+
|
|
76
|
+
def normalize(self, raw_item: dict) -> NormalizedItem:
|
|
77
|
+
"""Map a Slack message dict to NormalizedItem."""
|
|
78
|
+
ts = raw_item.get("ts", "")
|
|
79
|
+
channel_id = raw_item.get("_channel_id", "")
|
|
80
|
+
text = raw_item.get("text") or ""
|
|
81
|
+
|
|
82
|
+
title = text[:100].replace("\n", " ") if text else "(no text)"
|
|
83
|
+
body = text
|
|
84
|
+
|
|
85
|
+
user = raw_item.get("user") or raw_item.get("bot_id") or "unknown"
|
|
86
|
+
|
|
87
|
+
created_at = ts
|
|
88
|
+
|
|
89
|
+
source_url = (
|
|
90
|
+
f"https://slack.com/archives/{channel_id}/p{ts.replace('.', '')}"
|
|
91
|
+
if channel_id and ts
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return NormalizedItem(
|
|
96
|
+
source=self.source_name,
|
|
97
|
+
source_id=f"{channel_id}:{ts}",
|
|
98
|
+
title=title,
|
|
99
|
+
body=body,
|
|
100
|
+
status="open",
|
|
101
|
+
priority="medium",
|
|
102
|
+
assignee=None,
|
|
103
|
+
requester=user,
|
|
104
|
+
created_at=created_at,
|
|
105
|
+
updated_at=None,
|
|
106
|
+
tags=[],
|
|
107
|
+
custom_fields={"channel_id": channel_id},
|
|
108
|
+
raw_payload=raw_item,
|
|
109
|
+
source_url=source_url,
|
|
110
|
+
)
|