machina-cli 0.4.4__tar.gz → 0.4.7__tar.gz
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.
- {machina_cli-0.4.4 → machina_cli-0.4.7}/.github/workflows/release.yml +15 -5
- {machina_cli-0.4.4 → machina_cli-0.4.7}/CHANGELOG.md +9 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/PKG-INFO +1 -1
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/provision.py +83 -3
- {machina_cli-0.4.4 → machina_cli-0.4.7}/pyproject.toml +1 -1
- machina_cli-0.4.7/src/machina_cli/client.py +134 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/auth.py +71 -36
- machina_cli-0.4.4/src/machina_cli/client.py +0 -101
- {machina_cli-0.4.4 → machina_cli-0.4.7}/.github/workflows/ci.yml +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/.gitignore +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/CLAUDE.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/README.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/agentic-harness-loop.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/credentials.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/PLAYBOOK-SCORECARD.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/README.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/VALIDATION.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/context-verify.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/loop-tools-connector.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.gitignore +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/config.mts +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/theme/custom.css +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/theme/index.ts +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/README.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/agent.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/config.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/connector.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/credentials.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/deploy.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/document.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/execution.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/factory.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/login.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/loop.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/mapping.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/org.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/project.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/prompt.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/skills.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/sports.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/template.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/update.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/workflow.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/authentication.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/configuration.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/installation.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/quickstart.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/repl.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/running.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/index.md +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/package-lock.json +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/package.json +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/favicon.ico +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/machina-logo-dark.png +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/machina-logo-light.png +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/vercel.json +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/install.ps1 +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/install.sh +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/__init__.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/browser_auth.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/__init__.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/agent.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/config_cmd.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/connect.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/connector.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/credentials.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/deploy.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/document.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/execution.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/factory.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/loop.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/mapping.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/mcp.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/org.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/project.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/prompt.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/skills.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/sports.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/template.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/workflow.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/config.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/factory_client.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/loop_client.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/main.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/models/__init__.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/project_client.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/repl.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/updater.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/__init__.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_cli_output.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_connect.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_mcp_url.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_org_usage.py +0 -0
- {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_sports_integration.py +0 -0
|
@@ -78,15 +78,25 @@ jobs:
|
|
|
78
78
|
# Write to file for gh release
|
|
79
79
|
echo "$notes" > release_notes.md
|
|
80
80
|
|
|
81
|
-
- name: Create GitHub Release
|
|
81
|
+
- name: Create or update GitHub Release
|
|
82
82
|
env:
|
|
83
83
|
GH_TOKEN: ${{ github.token }}
|
|
84
84
|
run: |
|
|
85
85
|
tag="${GITHUB_REF#refs/tags/}"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
# Idempotent: a release may already exist if it was created from the GitHub
|
|
87
|
+
# UI / `gh release create` (which also creates the tag that triggers this
|
|
88
|
+
# workflow). In that case `gh release create` errors ("already exists") and
|
|
89
|
+
# the built binaries never get attached — leaving an empty release. So if the
|
|
90
|
+
# release exists, refresh its notes and upload the assets with --clobber.
|
|
91
|
+
if gh release view "$tag" >/dev/null 2>&1; then
|
|
92
|
+
gh release edit "$tag" --title "$tag" --notes-file release_notes.md
|
|
93
|
+
gh release upload "$tag" artifacts/**/* --clobber
|
|
94
|
+
else
|
|
95
|
+
gh release create "$tag" \
|
|
96
|
+
--title "$tag" \
|
|
97
|
+
--notes-file release_notes.md \
|
|
98
|
+
artifacts/**/*
|
|
99
|
+
fi
|
|
90
100
|
|
|
91
101
|
publish-pypi:
|
|
92
102
|
needs: [github-release]
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to machina-cli are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.7] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **The harness loop can now read documents and real conversations.** Its tool catalog gained `read_documents` (read any document on the project pod by name — copilot threads, harness sessions, fixtures, config docs, via the same `document_search` the MCP uses) and `fetch_conversations` (recent real SportingBOT end-user chat transcripts from PostHog — user context + bot answer + category, read from `posthog.ai_events`). Before, the loop only had `find_fixtures`/`calculate`/`get_datetime`/`echo`, so "analyse the recent chats and suggest bot improvements" got "give me the logs"; it now pulls live transcripts and returns grounded, evaluator-checked findings. (#41)
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`machina login --api-key` no longer discards a valid key.** The key was verified against `login/session`, which returns 5xx for API-key auth; the client read that as "key invalid", deleted the just-stored key, and silently fell back to the session token (printing "Authenticated as Unknown"). The client now never clears the key on a 5xx — it falls back for the current request only, and only when a session-token retry actually succeeds — and `login --api-key` verifies without that fallback and reports an honest result. The key is always kept. (#42)
|
|
12
|
+
- **Release pipeline no longer ships empty releases.** The `github-release` CI step used `gh release create`, which errors when the release already exists (e.g. cut from the GitHub UI, which also creates the triggering tag) and then dies before attaching the binaries. It is now idempotent — create, or upload assets onto the existing release. (#43)
|
|
13
|
+
|
|
5
14
|
## [0.4.4] - 2026-06-30
|
|
6
15
|
|
|
7
16
|
### Fixed
|
|
@@ -23,7 +23,7 @@ What it creates (maps to the Loop-Engineering five moves — see VALIDATION.md):
|
|
|
23
23
|
prompt loop-evaluate INDEPENDENT verifier: judge the answer (verification)
|
|
24
24
|
prompt loop-repair bounded self-repair of a rejected answer (Cap 8.2)
|
|
25
25
|
prompt loop-evaluate-2 re-verify the repaired answer (Cap 8.2)
|
|
26
|
-
connector loop-tools dispatcher: calculate / get_datetime / echo / find_fixtures
|
|
26
|
+
connector loop-tools dispatcher: calculate / get_datetime / echo / find_fixtures / read_documents / fetch_conversations
|
|
27
27
|
workflow loop-turn load -> ingest -> reason -> tool -> respond -> EVALUATE -> [repair -> re-eval] -> finalize
|
|
28
28
|
workflow loop-resume beat path: resume an orphaned `active` session (scheduling)
|
|
29
29
|
agent loop-runner executor path (inactive; CLI/MCP invokes it)
|
|
@@ -98,7 +98,7 @@ def _create(kind, body):
|
|
|
98
98
|
# --- loop-tools connector source (pyscript, exec'd from the DB at call time) ---
|
|
99
99
|
# Returns {status: True, data: {...}} — the connector contract; a bare dict is
|
|
100
100
|
# treated as a FAILED connector. Inputs arrive under request_data['params'].
|
|
101
|
-
LOOP_TOOLS_SRC = r'''"""Machina Loop Tools — meta-dispatcher (calculate/get_datetime/echo/find_fixtures)."""
|
|
101
|
+
LOOP_TOOLS_SRC = r'''"""Machina Loop Tools — meta-dispatcher (calculate/get_datetime/echo/find_fixtures/read_documents/fetch_conversations)."""
|
|
102
102
|
import ast, json, operator
|
|
103
103
|
from datetime import datetime, timezone
|
|
104
104
|
_OPS={ast.Add:operator.add,ast.Sub:operator.sub,ast.Mult:operator.mul,ast.Div:operator.truediv,ast.Pow:operator.pow,ast.Mod:operator.mod,ast.USub:operator.neg,ast.UAdd:operator.pos,ast.FloorDiv:operator.floordiv}
|
|
@@ -136,6 +136,78 @@ def _find_fixtures(a):
|
|
|
136
136
|
"home_analysis":(tf.get("home") or {}).get("analysis","")[:300] if v.get("has_pre_match_research") else "",
|
|
137
137
|
"away_analysis":(tf.get("away") or {}).get("analysis","")[:300] if v.get("has_pre_match_research") else ""})
|
|
138
138
|
return out if out else "no upcoming fixtures found"+((" for team "+team) if team else "")
|
|
139
|
+
def _read_documents(a):
|
|
140
|
+
# General doc reader: reads recent documents on THIS pod by exact name, using the
|
|
141
|
+
# SAME core.document.controller the MCP uses. Gives the loop eyes on its own pod
|
|
142
|
+
# (copilot-thread, harness_session, sportradar-fixture, config docs, ...).
|
|
143
|
+
try:
|
|
144
|
+
from core.document.controller import document_search
|
|
145
|
+
except Exception as ex:
|
|
146
|
+
return "read_documents unavailable: "+str(ex)
|
|
147
|
+
name=str(a.get("name","")).strip()
|
|
148
|
+
if not name:
|
|
149
|
+
return "read_documents needs name (e.g. copilot-thread, harness_session, sportradar-fixture)"
|
|
150
|
+
try: limit=int(a.get("limit",5))
|
|
151
|
+
except Exception: limit=5
|
|
152
|
+
contains=str(a.get("contains","")).strip().lower()
|
|
153
|
+
r=document_search(filters={"name":name},page=1,page_size=max(1,min(limit,10)),sorters=["created",-1])
|
|
154
|
+
dd=r.get("data") if isinstance(r,dict) else None
|
|
155
|
+
docs=dd.get("data") if isinstance(dd,dict) else (dd if isinstance(dd,list) else [])
|
|
156
|
+
out=[]
|
|
157
|
+
for d in (docs or []):
|
|
158
|
+
s=json.dumps(d.get("value",{}) or {},ensure_ascii=False,default=str)
|
|
159
|
+
if contains and contains not in s.lower(): continue
|
|
160
|
+
out.append({"name":d.get("name"),"created":str(d.get("created","")),"value":s[:900]})
|
|
161
|
+
return out if out else ("no '"+name+"' documents found")
|
|
162
|
+
def _fetch_conversations(a):
|
|
163
|
+
# Real SportingBOT end-user chats from PostHog. Verbatim text lives on the
|
|
164
|
+
# posthog.ai_events table (input/output_choices, anchored on trace_id, 30d retention);
|
|
165
|
+
# events carries only metadata. Key read from the posthog-surface-config doc (same as
|
|
166
|
+
# the surface-verify defense). Returns recent (user-context, bot-answer, category).
|
|
167
|
+
import urllib.request
|
|
168
|
+
try:
|
|
169
|
+
from core.document.controller import document_search
|
|
170
|
+
except Exception as ex:
|
|
171
|
+
return "fetch_conversations unavailable: "+str(ex)
|
|
172
|
+
key=""; pid_doc=""
|
|
173
|
+
try:
|
|
174
|
+
r=document_search(filters={"name":"posthog-surface-config"},page=1,page_size=1)
|
|
175
|
+
dd=r.get("data") if isinstance(r,dict) else None
|
|
176
|
+
docs=dd.get("data") if isinstance(dd,dict) else (dd if isinstance(dd,list) else [])
|
|
177
|
+
if docs:
|
|
178
|
+
v=docs[0].get("value",{}) or {}
|
|
179
|
+
key=v.get("query_key","") or v.get("posthog_query_key","") or v.get("key","") or v.get("posthog_key","")
|
|
180
|
+
pid_doc=str(v.get("project","") or "")
|
|
181
|
+
except Exception as ex:
|
|
182
|
+
return "fetch_conversations: cannot read posthog key: "+str(ex)
|
|
183
|
+
if not key or str(key).startswith("$"):
|
|
184
|
+
return "fetch_conversations: no posthog key in posthog-surface-config doc"
|
|
185
|
+
pid=str(a.get("project_id") or pid_doc or "257767").strip() or "257767"
|
|
186
|
+
try: limit=int(a.get("limit",4))
|
|
187
|
+
except Exception: limit=4
|
|
188
|
+
limit=max(1,min(limit,6))
|
|
189
|
+
cat=str(a.get("category","")).replace("'","").strip()
|
|
190
|
+
catf=("AND properties.sbot_category = '"+cat+"' ") if cat else ""
|
|
191
|
+
q=("WITH t AS (SELECT properties.$ai_trace_id AS tid, properties.sbot_category AS cat, timestamp AS ts "
|
|
192
|
+
"FROM events WHERE event='$ai_generation' AND timestamp >= now() - INTERVAL 72 HOUR "
|
|
193
|
+
"AND properties.$ai_trace_id != '' "+catf+
|
|
194
|
+
"ORDER BY timestamp DESC LIMIT "+str(limit)+") "
|
|
195
|
+
"SELECT t.cat AS category, right(toString(a.input),260) AS user_ctx, "
|
|
196
|
+
"substring(toString(a.output_choices),1,300) AS bot "
|
|
197
|
+
"FROM posthog.ai_events AS a INNER JOIN t ON a.trace_id=t.tid "
|
|
198
|
+
"ORDER BY t.ts DESC LIMIT "+str(limit))
|
|
199
|
+
body=json.dumps({"query":{"kind":"HogQLQuery","query":q}}).encode()
|
|
200
|
+
req=urllib.request.Request("https://us.posthog.com/api/projects/"+pid+"/query/",data=body,
|
|
201
|
+
headers={"Authorization":"Bearer "+key,"Content-Type":"application/json"},method="POST")
|
|
202
|
+
try:
|
|
203
|
+
with urllib.request.urlopen(req,timeout=45) as resp:
|
|
204
|
+
data=json.loads(resp.read() or "{}")
|
|
205
|
+
except Exception as ex:
|
|
206
|
+
return "fetch_conversations posthog error: "+str(ex)[:160]
|
|
207
|
+
out=[]
|
|
208
|
+
for row in (data.get("results") or []):
|
|
209
|
+
out.append({"category":row[0],"user_ctx":(row[1] or "")[:240],"bot":(row[2] or "")[:240]})
|
|
210
|
+
return out if out else "no recent conversations found"
|
|
139
211
|
def dispatch(request_data: dict) -> dict:
|
|
140
212
|
p=request_data.get("params",{}) or request_data
|
|
141
213
|
tool=(p.get("tool_name") or "").strip()
|
|
@@ -145,17 +217,23 @@ def dispatch(request_data: dict) -> dict:
|
|
|
145
217
|
except Exception:
|
|
146
218
|
args={}
|
|
147
219
|
if tool=="find_fixtures": r=_find_fixtures(args)
|
|
220
|
+
elif tool=="read_documents": r=_read_documents(args)
|
|
221
|
+
elif tool=="fetch_conversations": r=_fetch_conversations(args)
|
|
148
222
|
elif tool=="get_datetime": r=datetime.now(timezone.utc).strftime("%A, %d %B %Y, %H:%M UTC")
|
|
149
223
|
elif tool=="calculate": r=_calc(args)
|
|
150
224
|
elif tool=="echo": r=str(args.get("text",""))
|
|
151
225
|
else: r="unknown tool: "+tool
|
|
152
226
|
return {"status": True, "data": {"tool_result": json.dumps(r, ensure_ascii=False, default=str)[:2800],
|
|
153
|
-
"tool_ok": tool in ("find_fixtures","get_datetime","calculate","echo")}}
|
|
227
|
+
"tool_ok": tool in ("find_fixtures","read_documents","fetch_conversations","get_datetime","calculate","echo")}}
|
|
154
228
|
'''
|
|
155
229
|
|
|
156
230
|
CATALOG = [
|
|
157
231
|
{"name": "find_fixtures", "params_hint": "team(str), limit(int=5)",
|
|
158
232
|
"description": "Upcoming football fixtures (real project data) + AI pre-match analysis. Use for schedule / who-plays / pre-match questions."},
|
|
233
|
+
{"name": "read_documents", "params_hint": "name*(str), limit(int=5), contains(str?)",
|
|
234
|
+
"description": "Read recent documents stored on THIS project pod by exact name (e.g. copilot-thread, harness_session, sportradar-fixture, config docs). Use to inspect saved conversations/state on the pod before answering."},
|
|
235
|
+
{"name": "fetch_conversations", "params_hint": "limit(int=4), category(str?)",
|
|
236
|
+
"description": "Recent REAL SportingBOT end-user chat transcripts from PostHog (user context + bot answer + category). Use to analyze conversation quality and suggest concrete bot improvements."},
|
|
159
237
|
{"name": "calculate", "params_hint": "expression*(str)", "description": "Evaluate arithmetic; never compute it yourself."},
|
|
160
238
|
{"name": "get_datetime", "params_hint": "(no args)", "description": "Current UTC date/time."},
|
|
161
239
|
{"name": "echo", "params_hint": "text*(str)", "description": "Echo text back verbatim."},
|
|
@@ -247,6 +325,8 @@ REASON_INSTR = (
|
|
|
247
325
|
"- For ANY math -> needs_tool_call=true, call calculate {expression}. Never compute yourself.\n"
|
|
248
326
|
"- For current date/time -> call get_datetime.\n"
|
|
249
327
|
"- For upcoming matches / schedule / pre-match analysis -> call find_fixtures {team?, limit?}.\n"
|
|
328
|
+
"- To inspect saved documents/conversations/state on this pod (copilot-thread, harness_session, fixtures, config) -> call read_documents {name, limit?, contains?}.\n"
|
|
329
|
+
"- To analyze recent SportingBOT user chats or suggest bot improvements -> call fetch_conversations {limit?, category?}.\n"
|
|
250
330
|
"- To repeat text -> call echo {text}.\n"
|
|
251
331
|
"- Otherwise answer directly: needs_tool_call=false.\n"
|
|
252
332
|
"name = exact tool from _3-available-tools; arguments_json = JSON-stringified args ('{}' if none). short_message = 1 line.")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""HTTP client for Machina Core API."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from machina_cli.config import get_api_url, get_credential, resolve_auth_token
|
|
9
|
+
|
|
10
|
+
console = Console(stderr=True)
|
|
11
|
+
|
|
12
|
+
TIMEOUT = 30.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MachinaClient:
|
|
16
|
+
"""HTTP client that handles authentication and error formatting."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_url: Optional[str] = None):
|
|
19
|
+
self.api_url = (api_url or get_api_url()).rstrip("/")
|
|
20
|
+
|
|
21
|
+
def _headers(self) -> dict:
|
|
22
|
+
header_name, token = resolve_auth_token()
|
|
23
|
+
headers = {"Content-Type": "application/json"}
|
|
24
|
+
if header_name and token:
|
|
25
|
+
headers[header_name] = token
|
|
26
|
+
else:
|
|
27
|
+
console.print("[red]Not authenticated. Run `machina login` first.[/red]")
|
|
28
|
+
raise SystemExit(1)
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _handle_response(self, response: httpx.Response, quiet: bool = False) -> dict:
|
|
32
|
+
data = {}
|
|
33
|
+
try:
|
|
34
|
+
data = response.json()
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Extract error message from response body if available
|
|
39
|
+
error_msg = ""
|
|
40
|
+
if isinstance(data, dict):
|
|
41
|
+
error = data.get("error", {})
|
|
42
|
+
if isinstance(error, dict):
|
|
43
|
+
error_msg = error.get("message", "")
|
|
44
|
+
|
|
45
|
+
def emit(*args):
|
|
46
|
+
if not quiet:
|
|
47
|
+
console.print(*args)
|
|
48
|
+
|
|
49
|
+
if response.status_code == 401:
|
|
50
|
+
emit(f"[red]{error_msg or 'Session expired.'}[/red]")
|
|
51
|
+
emit("[yellow]Run `machina login` to re-authenticate.[/yellow]")
|
|
52
|
+
raise SystemExit(1)
|
|
53
|
+
if response.status_code == 403:
|
|
54
|
+
emit(f"[red]{error_msg or 'Permission denied.'}[/red]")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
if response.status_code == 404:
|
|
57
|
+
emit(f"[red]{error_msg or 'Resource not found.'}[/red]")
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
if response.status_code >= 500:
|
|
60
|
+
emit(f"[red]{error_msg or 'Server error. Try again later.'}[/red]")
|
|
61
|
+
raise SystemExit(1)
|
|
62
|
+
|
|
63
|
+
if isinstance(data, dict) and data.get("status") == "error":
|
|
64
|
+
emit(f"[red]Error: {error_msg or 'Unknown error'}[/red]")
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
70
|
+
"""Execute a request, optionally falling back from API key to session token on 5xx."""
|
|
71
|
+
url = f"{self.api_url}/{path.lstrip('/')}"
|
|
72
|
+
skip_auth = kwargs.pop("skip_auth", False)
|
|
73
|
+
allow_fallback = kwargs.pop("allow_fallback", True)
|
|
74
|
+
quiet = kwargs.pop("quiet", False)
|
|
75
|
+
headers = {"Content-Type": "application/json"} if skip_auth else self._headers()
|
|
76
|
+
try:
|
|
77
|
+
with httpx.Client(timeout=TIMEOUT) as client:
|
|
78
|
+
response = getattr(client, method)(url, headers=headers, **kwargs)
|
|
79
|
+
|
|
80
|
+
# A 5xx while sending an API key is ambiguous: the key may be invalid,
|
|
81
|
+
# the endpoint may not accept API-key auth (it 500s instead of 401), or
|
|
82
|
+
# the server may simply be faulting. Fall back to the session token for
|
|
83
|
+
# THIS request only — never delete the stored key on a 5xx (that would
|
|
84
|
+
# silently discard a possibly-valid key, e.g. during `login --api-key`).
|
|
85
|
+
if allow_fallback and response.status_code >= 500 and headers.get("X-Api-Token"):
|
|
86
|
+
session = get_credential("session_token")
|
|
87
|
+
if session:
|
|
88
|
+
retry_headers = {k: v for k, v in headers.items() if k != "X-Api-Token"}
|
|
89
|
+
retry_headers["X-Session-Token"] = session
|
|
90
|
+
retry = getattr(client, method)(url, headers=retry_headers, **kwargs)
|
|
91
|
+
if retry.status_code < 500:
|
|
92
|
+
if not quiet:
|
|
93
|
+
console.print(
|
|
94
|
+
"[yellow]API key not accepted by the server here; "
|
|
95
|
+
"used your session token for this request.[/yellow]"
|
|
96
|
+
)
|
|
97
|
+
response = retry
|
|
98
|
+
|
|
99
|
+
return self._handle_response(response, quiet=quiet)
|
|
100
|
+
except httpx.ConnectError:
|
|
101
|
+
if not quiet:
|
|
102
|
+
console.print(
|
|
103
|
+
f"[red]Cannot reach Machina API at {self.api_url}. Check your connection.[/red]"
|
|
104
|
+
)
|
|
105
|
+
raise SystemExit(1)
|
|
106
|
+
|
|
107
|
+
def get(
|
|
108
|
+
self,
|
|
109
|
+
path: str,
|
|
110
|
+
params: Optional[dict] = None,
|
|
111
|
+
allow_fallback: bool = True,
|
|
112
|
+
quiet: bool = False,
|
|
113
|
+
) -> dict:
|
|
114
|
+
return self._request("get", path, params=params, allow_fallback=allow_fallback, quiet=quiet)
|
|
115
|
+
|
|
116
|
+
def post(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
json_data: Optional[dict] = None,
|
|
120
|
+
skip_auth: bool = False,
|
|
121
|
+
allow_fallback: bool = True,
|
|
122
|
+
quiet: bool = False,
|
|
123
|
+
) -> dict:
|
|
124
|
+
return self._request(
|
|
125
|
+
"post",
|
|
126
|
+
path,
|
|
127
|
+
json=json_data or {},
|
|
128
|
+
skip_auth=skip_auth,
|
|
129
|
+
allow_fallback=allow_fallback,
|
|
130
|
+
quiet=quiet,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def delete(self, path: str, allow_fallback: bool = True, quiet: bool = False) -> dict:
|
|
134
|
+
return self._request("delete", path, allow_fallback=allow_fallback, quiet=quiet)
|
|
@@ -68,29 +68,44 @@ def do_login(
|
|
|
68
68
|
# Mode 1: API key
|
|
69
69
|
if api_key:
|
|
70
70
|
store_credential("api_key", api_key)
|
|
71
|
-
console.print("[green]API key stored
|
|
71
|
+
console.print("[green]API key stored.[/green]")
|
|
72
72
|
|
|
73
|
-
#
|
|
73
|
+
# Best-effort identity. CRITICAL: allow_fallback=False so a 5xx never swaps in
|
|
74
|
+
# the session token (which would report the wrong identity), and the key is
|
|
75
|
+
# NEVER cleared on failure — verification is informational only. quiet=True so
|
|
76
|
+
# we own the messaging instead of leaking raw error lines. A 5xx here is
|
|
77
|
+
# commonly the server faulting on API-key auth, not a bad key.
|
|
74
78
|
client = MachinaClient()
|
|
79
|
+
label = ""
|
|
75
80
|
try:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
data = (
|
|
82
|
+
client.get("login/session", allow_fallback=False, quiet=True).get("data", {}) or {}
|
|
83
|
+
)
|
|
84
|
+
label = data.get("name") or data.get("email") or ""
|
|
80
85
|
except SystemExit:
|
|
81
|
-
# login/session may not support API key auth — try listing orgs instead
|
|
82
86
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
orgs = (
|
|
88
|
+
client.post(
|
|
89
|
+
"user/organizations/search",
|
|
90
|
+
{"filters": {}, "page": 1, "page_size": 1, "sorters": ["name", 1]},
|
|
91
|
+
allow_fallback=False,
|
|
92
|
+
quiet=True,
|
|
93
|
+
).get("data", [])
|
|
94
|
+
or []
|
|
95
|
+
)
|
|
87
96
|
if orgs:
|
|
88
|
-
|
|
89
|
-
console.print(f"Authenticated — org: [bold]{org_name}[/bold]")
|
|
90
|
-
else:
|
|
91
|
-
console.print("[green]API key verified.[/green]")
|
|
97
|
+
label = orgs[0].get("organization_name") or orgs[0].get("name") or ""
|
|
92
98
|
except SystemExit:
|
|
93
|
-
|
|
99
|
+
label = ""
|
|
100
|
+
|
|
101
|
+
if label:
|
|
102
|
+
console.print(f"Authenticated as [bold]{label}[/bold]")
|
|
103
|
+
else:
|
|
104
|
+
console.print(
|
|
105
|
+
"[yellow]Key stored, but the server didn't confirm it (the auth endpoints "
|
|
106
|
+
"may not accept API keys, or returned an error). The key is kept — run a "
|
|
107
|
+
"command to use it, or set the MACHINA_API_KEY env var.[/yellow]"
|
|
108
|
+
)
|
|
94
109
|
return
|
|
95
110
|
|
|
96
111
|
# Mode 3: Username/password (explicit flag)
|
|
@@ -146,17 +161,27 @@ def do_login(
|
|
|
146
161
|
|
|
147
162
|
@app.command()
|
|
148
163
|
def login(
|
|
149
|
-
api_key: Optional[str] = typer.Option(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
164
|
+
api_key: Optional[str] = typer.Option(
|
|
165
|
+
None, "--api-key", "-k", help="Authenticate with an API key"
|
|
166
|
+
),
|
|
167
|
+
with_credentials: bool = typer.Option(
|
|
168
|
+
False, "--with-credentials", help="Use username/password instead of browser"
|
|
169
|
+
),
|
|
170
|
+
username: Optional[str] = typer.Option(
|
|
171
|
+
None, "--username", "-u", help="Username (requires --with-credentials)"
|
|
172
|
+
),
|
|
173
|
+
password: Optional[str] = typer.Option(
|
|
174
|
+
None, "--password", "-p", help="Password (requires --with-credentials)"
|
|
175
|
+
),
|
|
153
176
|
):
|
|
154
177
|
"""Login to the Machina platform.
|
|
155
178
|
|
|
156
179
|
Default: opens browser for Clerk authentication (magic link / SSO).
|
|
157
180
|
Use --api-key for CI/CD, or --with-credentials for username/password.
|
|
158
181
|
"""
|
|
159
|
-
do_login(
|
|
182
|
+
do_login(
|
|
183
|
+
api_key=api_key, username=username, password=password, with_credentials=with_credentials
|
|
184
|
+
)
|
|
160
185
|
|
|
161
186
|
|
|
162
187
|
@app.command()
|
|
@@ -168,6 +193,7 @@ def logout():
|
|
|
168
193
|
# Open browser logout to clear session cookies (Clerk + machina session)
|
|
169
194
|
session_url = get_config("session_url") or "https://session.machina.gg"
|
|
170
195
|
import webbrowser
|
|
196
|
+
|
|
171
197
|
console.print("[dim]Opening browser to clear session cookies...[/dim]")
|
|
172
198
|
webbrowser.open(f"{session_url}/logout")
|
|
173
199
|
console.print("[green]Logged out completely.[/green]")
|
|
@@ -186,11 +212,14 @@ def clear_session():
|
|
|
186
212
|
# Open browser to clear session cookies on the SESSION app
|
|
187
213
|
session_url = get_config("session_url") or "https://session.machina.gg"
|
|
188
214
|
import webbrowser
|
|
215
|
+
|
|
189
216
|
console.print("[dim]Opening browser to clear server-side cookies...[/dim]")
|
|
190
217
|
webbrowser.open(f"{session_url}/logout")
|
|
191
218
|
|
|
192
219
|
console.print()
|
|
193
|
-
console.print(
|
|
220
|
+
console.print(
|
|
221
|
+
"[bold]Session fully cleared.[/bold] Run [bold #FF5C1F]machina login[/bold #FF5C1F] to re-authenticate."
|
|
222
|
+
)
|
|
194
223
|
|
|
195
224
|
|
|
196
225
|
@app.command()
|
|
@@ -222,19 +251,25 @@ def whoami(
|
|
|
222
251
|
auth_method = "API Key" if header_name == "X-Api-Token" else "Session Token"
|
|
223
252
|
|
|
224
253
|
if json_output:
|
|
225
|
-
print(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
254
|
+
print(
|
|
255
|
+
json.dumps(
|
|
256
|
+
{
|
|
257
|
+
"authenticated": True,
|
|
258
|
+
"name": user_data.get("name"),
|
|
259
|
+
"email": user_data.get("email"),
|
|
260
|
+
"user_id": user_data.get("_id", user_data.get("id")),
|
|
261
|
+
"auth_method": auth_method,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
)
|
|
232
265
|
return
|
|
233
266
|
|
|
234
|
-
console.print(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
267
|
+
console.print(
|
|
268
|
+
Panel.fit(
|
|
269
|
+
f"[bold]Name:[/bold] {user_data.get('name', 'N/A')}\n"
|
|
270
|
+
f"[bold]Email:[/bold] {user_data.get('email', 'N/A')}\n"
|
|
271
|
+
f"[bold]User ID:[/bold] {user_data.get('_id', user_data.get('id', 'N/A'))}\n"
|
|
272
|
+
f"[bold]Auth:[/bold] {auth_method}",
|
|
273
|
+
title="Current User",
|
|
274
|
+
)
|
|
275
|
+
)
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""HTTP client for Machina Core API."""
|
|
2
|
-
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from rich.console import Console
|
|
7
|
-
|
|
8
|
-
from machina_cli.config import _clear_credential, get_api_url, get_credential, resolve_auth_token
|
|
9
|
-
|
|
10
|
-
console = Console(stderr=True)
|
|
11
|
-
|
|
12
|
-
TIMEOUT = 30.0
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class MachinaClient:
|
|
16
|
-
"""HTTP client that handles authentication and error formatting."""
|
|
17
|
-
|
|
18
|
-
def __init__(self, api_url: Optional[str] = None):
|
|
19
|
-
self.api_url = (api_url or get_api_url()).rstrip("/")
|
|
20
|
-
|
|
21
|
-
def _headers(self) -> dict:
|
|
22
|
-
header_name, token = resolve_auth_token()
|
|
23
|
-
headers = {"Content-Type": "application/json"}
|
|
24
|
-
if header_name and token:
|
|
25
|
-
headers[header_name] = token
|
|
26
|
-
else:
|
|
27
|
-
console.print("[red]Not authenticated. Run `machina login` first.[/red]")
|
|
28
|
-
raise SystemExit(1)
|
|
29
|
-
return headers
|
|
30
|
-
|
|
31
|
-
def _handle_response(self, response: httpx.Response) -> dict:
|
|
32
|
-
data = {}
|
|
33
|
-
try:
|
|
34
|
-
data = response.json()
|
|
35
|
-
except Exception:
|
|
36
|
-
pass
|
|
37
|
-
|
|
38
|
-
# Extract error message from response body if available
|
|
39
|
-
error_msg = ""
|
|
40
|
-
if isinstance(data, dict):
|
|
41
|
-
error = data.get("error", {})
|
|
42
|
-
if isinstance(error, dict):
|
|
43
|
-
error_msg = error.get("message", "")
|
|
44
|
-
|
|
45
|
-
if response.status_code == 401:
|
|
46
|
-
console.print(f"[red]{error_msg or 'Session expired.'}[/red]")
|
|
47
|
-
console.print("[yellow]Run `machina login` to re-authenticate.[/yellow]")
|
|
48
|
-
raise SystemExit(1)
|
|
49
|
-
if response.status_code == 403:
|
|
50
|
-
msg = error_msg or "Permission denied."
|
|
51
|
-
console.print(f"[red]{msg}[/red]")
|
|
52
|
-
raise SystemExit(1)
|
|
53
|
-
if response.status_code == 404:
|
|
54
|
-
msg = error_msg or "Resource not found."
|
|
55
|
-
console.print(f"[red]{msg}[/red]")
|
|
56
|
-
raise SystemExit(1)
|
|
57
|
-
if response.status_code >= 500:
|
|
58
|
-
msg = error_msg or "Server error. Try again later."
|
|
59
|
-
console.print(f"[red]{msg}[/red]")
|
|
60
|
-
raise SystemExit(1)
|
|
61
|
-
|
|
62
|
-
if isinstance(data, dict) and data.get("status") == "error":
|
|
63
|
-
console.print(f"[red]Error: {error_msg or 'Unknown error'}[/red]")
|
|
64
|
-
raise SystemExit(1)
|
|
65
|
-
|
|
66
|
-
return data
|
|
67
|
-
|
|
68
|
-
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
69
|
-
"""Execute request with automatic API key fallback on 500."""
|
|
70
|
-
url = f"{self.api_url}/{path.lstrip('/')}"
|
|
71
|
-
skip_auth = kwargs.pop("skip_auth", False)
|
|
72
|
-
headers = {"Content-Type": "application/json"} if skip_auth else self._headers()
|
|
73
|
-
try:
|
|
74
|
-
with httpx.Client(timeout=TIMEOUT) as client:
|
|
75
|
-
response = getattr(client, method)(url, headers=headers, **kwargs)
|
|
76
|
-
|
|
77
|
-
# If API key returns 500, it may be invalid/missing from Redis.
|
|
78
|
-
# Clear it and retry with session token if available.
|
|
79
|
-
if response.status_code >= 500 and headers.get("X-Api-Token"):
|
|
80
|
-
if get_credential("session_token"):
|
|
81
|
-
console.print(
|
|
82
|
-
"[yellow]API key rejected by server. "
|
|
83
|
-
"Falling back to session token...[/yellow]"
|
|
84
|
-
)
|
|
85
|
-
_clear_credential("api_key")
|
|
86
|
-
headers = self._headers()
|
|
87
|
-
response = getattr(client, method)(url, headers=headers, **kwargs)
|
|
88
|
-
|
|
89
|
-
return self._handle_response(response)
|
|
90
|
-
except httpx.ConnectError:
|
|
91
|
-
console.print(f"[red]Cannot reach Machina API at {self.api_url}. Check your connection.[/red]")
|
|
92
|
-
raise SystemExit(1)
|
|
93
|
-
|
|
94
|
-
def get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
95
|
-
return self._request("get", path, params=params)
|
|
96
|
-
|
|
97
|
-
def post(self, path: str, json_data: Optional[dict] = None, skip_auth: bool = False) -> dict:
|
|
98
|
-
return self._request("post", path, json=json_data or {}, skip_auth=skip_auth)
|
|
99
|
-
|
|
100
|
-
def delete(self, path: str) -> dict:
|
|
101
|
-
return self._request("delete", path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|