memstack-skill-loader 4.1.0__tar.gz → 4.2.0__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.
- {memstack_skill_loader-4.1.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.2.0}/PKG-INFO +2 -1
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/pyproject.toml +2 -1
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/__init__.py +1 -1
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/__main__.py +9 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/agent_runner.py +211 -41
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/dashboard.html +319 -63
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/dashboard.py +129 -95
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/__init__.py +0 -0
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/body_parser.py +107 -0
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/compressor.py +70 -0
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/forwarder.py +47 -0
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/server.py +227 -0
- memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/stats_tracker.py +103 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/stats.py +144 -2
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0/src/memstack_skill_loader.egg-info}/PKG-INFO +2 -1
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +7 -1
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/requires.txt +1 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/MANIFEST.in +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/README.md +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/setup.cfg +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/categories.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/compression.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/config.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/indexer.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/license.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/memory_db.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/search.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/server.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/skill_config.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/version_check.py +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
- {memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memstack-skill-loader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: mcp>=1.0.0
|
|
@@ -8,3 +8,4 @@ Requires-Dist: lancedb>=0.6.0
|
|
|
8
8
|
Requires-Dist: sentence-transformers>=2.2.0
|
|
9
9
|
Requires-Dist: pyarrow>=14.0.0
|
|
10
10
|
Requires-Dist: httpx>=0.24.0
|
|
11
|
+
Requires-Dist: aiohttp>=3.9
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memstack-skill-loader"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.2.0"
|
|
8
8
|
description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
@@ -13,6 +13,7 @@ dependencies = [
|
|
|
13
13
|
"sentence-transformers>=2.2.0",
|
|
14
14
|
"pyarrow>=14.0.0",
|
|
15
15
|
"httpx>=0.24.0",
|
|
16
|
+
"aiohttp>=3.9",
|
|
16
17
|
]
|
|
17
18
|
|
|
18
19
|
[project.scripts]
|
{memstack_skill_loader-4.1.0 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/__main__.py
RENAMED
|
@@ -78,6 +78,15 @@ def main():
|
|
|
78
78
|
elif len(sys.argv) > 2 and sys.argv[1] == "run":
|
|
79
79
|
task = " ".join(sys.argv[2:])
|
|
80
80
|
_run_agents(task)
|
|
81
|
+
elif len(sys.argv) > 1 and sys.argv[1] == "proxy":
|
|
82
|
+
import argparse
|
|
83
|
+
parser = argparse.ArgumentParser(prog="memstack proxy")
|
|
84
|
+
parser.add_argument("--port", type=int, default=8787)
|
|
85
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
86
|
+
parser.add_argument("--verbose", action="store_true", help="Show parser diagnostics")
|
|
87
|
+
args = parser.parse_args(sys.argv[2:])
|
|
88
|
+
from .proxy.server import start_proxy
|
|
89
|
+
start_proxy(host=args.host, port=args.port, verbose=args.verbose)
|
|
81
90
|
elif len(sys.argv) > 1 and sys.argv[1] == "run":
|
|
82
91
|
print("Usage: python -m memstack_skill_loader run \"Your task description\"",
|
|
83
92
|
file=sys.stderr)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Agent Runner v4.0 - MemStack autonomous agent orchestration
|
|
2
|
-
"""Agent Runner — orchestrates Claude Code agents via
|
|
2
|
+
"""Agent Runner — orchestrates Claude Code agents via interactive streaming."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
@@ -14,7 +14,42 @@ from typing import Optional
|
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
17
|
-
from .stats import log_agent_invocation
|
|
17
|
+
from .stats import log_agent_invocation, log_api_cost
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Anthropic model pricing (per million tokens, May 2026)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
MODEL_PRICING = {
|
|
25
|
+
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
|
26
|
+
"claude-opus-4-6": {"input": 5.0, "output": 25.0},
|
|
27
|
+
"claude-opus-4-7": {"input": 5.0, "output": 25.0},
|
|
28
|
+
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
|
29
|
+
}
|
|
30
|
+
# Cache read: 90% discount on input rate
|
|
31
|
+
# Cache creation: 25% premium on input rate
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _calculate_api_cost(
|
|
35
|
+
model: str,
|
|
36
|
+
input_tokens: int,
|
|
37
|
+
output_tokens: int,
|
|
38
|
+
cache_read_tokens: int = 0,
|
|
39
|
+
cache_creation_tokens: int = 0,
|
|
40
|
+
) -> float:
|
|
41
|
+
"""Calculate USD cost from token counts and model pricing."""
|
|
42
|
+
pricing = MODEL_PRICING.get(model, MODEL_PRICING["claude-sonnet-4-6"])
|
|
43
|
+
input_rate = pricing["input"]
|
|
44
|
+
output_rate = pricing["output"]
|
|
45
|
+
|
|
46
|
+
cost = (input_tokens / 1_000_000 * input_rate
|
|
47
|
+
+ output_tokens / 1_000_000 * output_rate)
|
|
48
|
+
if cache_read_tokens > 0:
|
|
49
|
+
cost += cache_read_tokens / 1_000_000 * input_rate * 0.1
|
|
50
|
+
if cache_creation_tokens > 0:
|
|
51
|
+
cost += cache_creation_tokens / 1_000_000 * input_rate * 1.25
|
|
52
|
+
return round(cost, 6)
|
|
18
53
|
|
|
19
54
|
|
|
20
55
|
# ---------------------------------------------------------------------------
|
|
@@ -24,18 +59,47 @@ from .stats import log_agent_invocation
|
|
|
24
59
|
STATE_DIR = Path.home() / ".memstack" / "agent-runner"
|
|
25
60
|
STATE_FILE = STATE_DIR / "state.json"
|
|
26
61
|
|
|
27
|
-
AGENT_TIMEOUT = 3600 # seconds per
|
|
62
|
+
AGENT_TIMEOUT = 3600 # seconds per agent invocation (default 60 minutes)
|
|
28
63
|
MAX_ITERATIONS = 2
|
|
29
64
|
|
|
30
65
|
ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "")
|
|
31
66
|
API_KEY_FILE = Path.home() / ".memstack" / "api_key"
|
|
67
|
+
OAUTH_TOKEN_FILE = Path.home() / ".memstack" / "oauth_token"
|
|
32
68
|
API_DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
33
69
|
API_MAX_TOKENS = 16000
|
|
34
70
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
# API key loading moved to _load_api_key() — called on-demand per agent invocation.
|
|
72
|
+
# Do NOT set os.environ["ANTHROPIC_API_KEY"] at module load. It defeats subscription mode.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_api_key() -> str | None:
|
|
76
|
+
"""Get the Anthropic API key from environment or stored config file."""
|
|
77
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
78
|
+
if key:
|
|
79
|
+
return key
|
|
80
|
+
try:
|
|
81
|
+
if API_KEY_FILE.is_file():
|
|
82
|
+
stored = API_KEY_FILE.read_text(encoding="utf-8").strip()
|
|
83
|
+
if stored:
|
|
84
|
+
return stored
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def _load_oauth_token() -> str | None:
|
|
90
|
+
"""Get the OAuth token from environment or stored config file."""
|
|
91
|
+
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
|
92
|
+
if token:
|
|
93
|
+
return token
|
|
94
|
+
try:
|
|
95
|
+
if OAUTH_TOKEN_FILE.is_file():
|
|
96
|
+
stored = OAUTH_TOKEN_FILE.read_text(encoding="utf-8").strip()
|
|
97
|
+
if stored:
|
|
98
|
+
return stored
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
return None
|
|
102
|
+
|
|
39
103
|
|
|
40
104
|
SYSTEM_PROMPTS = {
|
|
41
105
|
"manager": (
|
|
@@ -184,25 +248,50 @@ _VALID_AGENT_MODES = ("api", "subscription")
|
|
|
184
248
|
|
|
185
249
|
|
|
186
250
|
def _default_agent_modes() -> dict:
|
|
187
|
-
"""Return default execution modes
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
251
|
+
"""Return default execution modes — always subscription.
|
|
252
|
+
|
|
253
|
+
The API key enables the *option* to switch to API mode in the dashboard,
|
|
254
|
+
but never pre-selects it.
|
|
255
|
+
"""
|
|
191
256
|
return {"manager": "subscription", "builder": "subscription", "reviewer": "subscription"}
|
|
192
257
|
|
|
193
258
|
|
|
194
259
|
def load_agent_modes() -> dict:
|
|
195
|
-
"""Load agent execution modes from config, falling back to defaults.
|
|
260
|
+
"""Load agent execution modes from config, falling back to defaults.
|
|
261
|
+
|
|
262
|
+
On first run (no ``_agent_modes`` key in config), the computed defaults
|
|
263
|
+
are persisted immediately so subsequent loads read an explicit saved state.
|
|
264
|
+
"""
|
|
196
265
|
cfg = load_builder_tools_config()
|
|
197
266
|
stored = cfg.get("_agent_modes", {})
|
|
267
|
+
first_run = "_agent_modes" not in cfg
|
|
198
268
|
defaults = _default_agent_modes()
|
|
199
269
|
modes = {}
|
|
200
270
|
for agent in ("manager", "builder", "reviewer"):
|
|
201
271
|
val = stored.get(agent, "")
|
|
202
272
|
modes[agent] = val if val in _VALID_AGENT_MODES else defaults[agent]
|
|
273
|
+
if first_run:
|
|
274
|
+
save_agent_modes(modes)
|
|
203
275
|
return modes
|
|
204
276
|
|
|
205
277
|
|
|
278
|
+
def _load_agent_display_names() -> dict[str, str]:
|
|
279
|
+
"""Load custom agent display names from user profile, falling back to role names."""
|
|
280
|
+
profile_path = Path.home() / ".memstack" / "user_profile.json"
|
|
281
|
+
names = {"manager": "manager", "builder": "builder", "reviewer": "reviewer"}
|
|
282
|
+
try:
|
|
283
|
+
if profile_path.exists():
|
|
284
|
+
data = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
285
|
+
stored = data.get("agent_names", {})
|
|
286
|
+
for role in names:
|
|
287
|
+
custom = stored.get(role, "").strip()
|
|
288
|
+
if custom:
|
|
289
|
+
names[role] = custom.lower()
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return names
|
|
293
|
+
|
|
294
|
+
|
|
206
295
|
def save_agent_modes(modes: dict) -> None:
|
|
207
296
|
"""Save agent execution modes to config."""
|
|
208
297
|
cfg = load_builder_tools_config()
|
|
@@ -329,22 +418,31 @@ def _extract_commit_from_reviewer(reviewer_output: str) -> str:
|
|
|
329
418
|
|
|
330
419
|
|
|
331
420
|
# ---------------------------------------------------------------------------
|
|
332
|
-
# Agent invocation via
|
|
421
|
+
# Agent invocation via interactive streaming
|
|
333
422
|
# ---------------------------------------------------------------------------
|
|
334
423
|
|
|
335
|
-
def _build_env() -> dict:
|
|
424
|
+
def _build_env(strip_api_key: bool = False, inject_api_key: str | None = None) -> dict:
|
|
336
425
|
"""Build environment for subprocess, ensuring Anthropic vars are passed."""
|
|
337
426
|
env = os.environ.copy()
|
|
338
427
|
env.pop("MEMSTACK_ENABLE_TTS", None)
|
|
428
|
+
# Strip API key for subscription mode so CC uses subscription instead of API.
|
|
429
|
+
# Set strip_api_key=False to revert.
|
|
430
|
+
if strip_api_key:
|
|
431
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
432
|
+
oauth_token = _load_oauth_token()
|
|
433
|
+
if oauth_token:
|
|
434
|
+
env["ANTHROPIC_AUTH_TOKEN"] = oauth_token
|
|
435
|
+
elif inject_api_key:
|
|
436
|
+
env["ANTHROPIC_API_KEY"] = inject_api_key
|
|
339
437
|
if ANTHROPIC_BASE_URL:
|
|
340
438
|
env["ANTHROPIC_BASE_URL"] = ANTHROPIC_BASE_URL
|
|
341
439
|
return env
|
|
342
440
|
|
|
343
441
|
|
|
344
|
-
def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
|
|
442
|
+
def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int, str]:
|
|
345
443
|
"""Extract text and token usage from claude --output-format stream-json.
|
|
346
444
|
|
|
347
|
-
Returns (text, input_tokens, output_tokens, cost_usd, context_tokens).
|
|
445
|
+
Returns (text, input_tokens, output_tokens, cost_usd, context_tokens, model).
|
|
348
446
|
context_tokens is the last iteration's total tokens (accurate context window fill).
|
|
349
447
|
"""
|
|
350
448
|
text_parts: list[str] = []
|
|
@@ -354,6 +452,7 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
|
|
|
354
452
|
output_tokens = 0
|
|
355
453
|
cost_usd = 0.0
|
|
356
454
|
context_tokens = 0
|
|
455
|
+
stream_model = ""
|
|
357
456
|
last_assistant_usage: dict = {}
|
|
358
457
|
for line in raw.splitlines():
|
|
359
458
|
line = line.strip()
|
|
@@ -365,6 +464,9 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
|
|
|
365
464
|
continue
|
|
366
465
|
msg_type = obj.get("type")
|
|
367
466
|
if msg_type == "assistant":
|
|
467
|
+
msg_model = obj.get("message", {}).get("model", "")
|
|
468
|
+
if msg_model:
|
|
469
|
+
stream_model = msg_model
|
|
368
470
|
usage = obj.get("message", {}).get("usage", {})
|
|
369
471
|
if usage:
|
|
370
472
|
last_assistant_usage = usage
|
|
@@ -399,7 +501,7 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
|
|
|
399
501
|
text = "[Agent completed with tool calls but no text summary]\n\n" + "\n\n".join(tool_parts)
|
|
400
502
|
else:
|
|
401
503
|
text = ""
|
|
402
|
-
return text, input_tokens, output_tokens, cost_usd, context_tokens
|
|
504
|
+
return text, input_tokens, output_tokens, cost_usd, context_tokens, stream_model
|
|
403
505
|
|
|
404
506
|
|
|
405
507
|
def _extract_text_from_stream_line(line: str) -> Optional[str]:
|
|
@@ -434,17 +536,18 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
|
|
|
434
536
|
log_path: Optional[Path] = None, timeout: int = 600,
|
|
435
537
|
model: str = "", session_id: Optional[str] = None,
|
|
436
538
|
working_dir: str = "",
|
|
539
|
+
display_name: str = "",
|
|
437
540
|
) -> tuple[str, int, int]:
|
|
438
541
|
"""Call the Anthropic Messages API directly via httpx.
|
|
439
542
|
|
|
440
543
|
Returns (text, input_tokens, output_tokens).
|
|
441
544
|
"""
|
|
442
|
-
api_key =
|
|
545
|
+
api_key = _load_api_key() or ""
|
|
443
546
|
base_url = ANTHROPIC_BASE_URL or "https://api.anthropic.com"
|
|
444
547
|
|
|
445
548
|
if not api_key and not ANTHROPIC_BASE_URL:
|
|
446
549
|
raise RuntimeError(
|
|
447
|
-
f"{name}:
|
|
550
|
+
f"{name}: No API key found (env or ~/.memstack/api_key) and no ANTHROPIC_BASE_URL proxy configured"
|
|
448
551
|
)
|
|
449
552
|
|
|
450
553
|
url = f"{base_url.rstrip('/')}/v1/messages"
|
|
@@ -502,22 +605,45 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
|
|
|
502
605
|
usage = data.get("usage", {})
|
|
503
606
|
input_tokens = usage.get("input_tokens", 0)
|
|
504
607
|
output_tokens = usage.get("output_tokens", 0)
|
|
608
|
+
cache_read_tokens = usage.get("cache_read_input_tokens", 0)
|
|
609
|
+
cache_creation_tokens = usage.get("cache_creation_input_tokens", 0)
|
|
610
|
+
total_input_tokens = input_tokens + cache_read_tokens
|
|
611
|
+
|
|
612
|
+
resp_model = data.get("model", model or "claude-sonnet-4-6")
|
|
613
|
+
actual_cost = _calculate_api_cost(
|
|
614
|
+
resp_model, input_tokens, output_tokens,
|
|
615
|
+
cache_read_tokens, cache_creation_tokens,
|
|
616
|
+
)
|
|
505
617
|
|
|
506
618
|
if log_path:
|
|
507
619
|
ts = time.strftime("%H:%M:%S")
|
|
508
620
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
509
621
|
f.write(f"[{ts}] Response: {len(output)} chars, "
|
|
510
|
-
f"in={
|
|
622
|
+
f"in={total_input_tokens} out={output_tokens}\n")
|
|
511
623
|
|
|
624
|
+
stats_name = display_name or name
|
|
512
625
|
try:
|
|
513
626
|
log_agent_invocation(
|
|
514
|
-
|
|
515
|
-
input_tokens=
|
|
627
|
+
stats_name, len(prompt), len(output), session_id, working_dir,
|
|
628
|
+
input_tokens=total_input_tokens, output_tokens=output_tokens, cost_usd=actual_cost,
|
|
629
|
+
)
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
_project = os.path.basename(working_dir) if working_dir else None
|
|
635
|
+
log_api_cost(
|
|
636
|
+
session_id=session_id, agent_name=stats_name, model=resp_model,
|
|
637
|
+
input_tokens=total_input_tokens, output_tokens=output_tokens,
|
|
638
|
+
actual_cost_usd=actual_cost, execution_mode="api",
|
|
639
|
+
project=_project,
|
|
640
|
+
cache_read_tokens=cache_read_tokens,
|
|
641
|
+
cache_creation_tokens=cache_creation_tokens,
|
|
516
642
|
)
|
|
517
643
|
except Exception:
|
|
518
644
|
pass
|
|
519
645
|
|
|
520
|
-
return output,
|
|
646
|
+
return output, total_input_tokens, output_tokens
|
|
521
647
|
|
|
522
648
|
|
|
523
649
|
# ---------------------------------------------------------------------------
|
|
@@ -528,13 +654,15 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
528
654
|
skip_permissions: bool = False,
|
|
529
655
|
session_id: Optional[str] = None, timeout: int = AGENT_TIMEOUT,
|
|
530
656
|
model: str = "", disallowed_tools: Optional[list[str]] = None,
|
|
531
|
-
session: Optional["Session"] = None
|
|
532
|
-
|
|
657
|
+
session: Optional["Session"] = None,
|
|
658
|
+
display_name: str = "",
|
|
659
|
+
execution_mode: str = "subscription") -> tuple[str, int, int, int]:
|
|
660
|
+
"""Run a single claude interactive streaming invocation and return the output."""
|
|
533
661
|
claude_bin = shutil.which("claude")
|
|
534
662
|
if not claude_bin:
|
|
535
663
|
raise FileNotFoundError("'claude' CLI not found on PATH")
|
|
536
664
|
|
|
537
|
-
cmd = [claude_bin, "--
|
|
665
|
+
cmd = [claude_bin, "--verbose", "--output-format", "stream-json", "--input-format", "stream-json"]
|
|
538
666
|
if skip_permissions:
|
|
539
667
|
cmd.append("--dangerously-skip-permissions")
|
|
540
668
|
if model:
|
|
@@ -552,23 +680,19 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
552
680
|
f.write(f"[{ts}] === Invoking {name} ===\n")
|
|
553
681
|
f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
|
|
554
682
|
|
|
555
|
-
prompt_dir = log_path.parent if log_path else Path.home() / ".memstack" / "agent-runner"
|
|
556
|
-
prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
557
|
-
prompt_file = prompt_dir / f"{name}_prompt.txt"
|
|
558
|
-
prompt_file.write_text(prompt, encoding="utf-8")
|
|
559
|
-
|
|
560
|
-
stdin_fh = open(prompt_file, "r", encoding="utf-8") # noqa: SIM115
|
|
561
683
|
global _current_process
|
|
562
684
|
proc = subprocess.Popen(
|
|
563
685
|
cmd,
|
|
564
|
-
stdin=
|
|
686
|
+
stdin=subprocess.PIPE,
|
|
565
687
|
stdout=subprocess.PIPE,
|
|
566
688
|
stderr=subprocess.PIPE,
|
|
567
689
|
cwd=working_dir,
|
|
568
|
-
env=_build_env(
|
|
690
|
+
env=_build_env(
|
|
691
|
+
strip_api_key=(execution_mode == "subscription"),
|
|
692
|
+
inject_api_key=_load_api_key() if execution_mode == "api" else None,
|
|
693
|
+
),
|
|
569
694
|
creationflags=0,
|
|
570
695
|
)
|
|
571
|
-
stdin_fh.close()
|
|
572
696
|
with _lock:
|
|
573
697
|
_current_process = proc
|
|
574
698
|
|
|
@@ -603,10 +727,24 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
603
727
|
for raw_line in proc.stderr:
|
|
604
728
|
stderr_chunks.append(raw_line.decode("utf-8", errors="replace"))
|
|
605
729
|
|
|
730
|
+
def _write_stdin() -> None:
|
|
731
|
+
try:
|
|
732
|
+
msg = json.dumps({"type": "user", "message": {"role": "user", "content": prompt}})
|
|
733
|
+
proc.stdin.write((msg + "\n").encode("utf-8"))
|
|
734
|
+
proc.stdin.flush()
|
|
735
|
+
finally:
|
|
736
|
+
proc.stdin.close()
|
|
737
|
+
|
|
738
|
+
# Start reader threads BEFORE writing stdin to prevent pipe deadlock.
|
|
739
|
+
# On Windows, anonymous pipes have a ~4KB buffer. Large prompts exceed
|
|
740
|
+
# this, blocking the write until the subprocess reads. If the subprocess
|
|
741
|
+
# writes init events to stdout first, nobody drains them — deadlock.
|
|
606
742
|
t_out = threading.Thread(target=_read_stdout, daemon=True)
|
|
607
743
|
t_err = threading.Thread(target=_read_stderr, daemon=True)
|
|
744
|
+
t_in = threading.Thread(target=_write_stdin, daemon=True)
|
|
608
745
|
t_out.start()
|
|
609
746
|
t_err.start()
|
|
747
|
+
t_in.start()
|
|
610
748
|
|
|
611
749
|
watchdog = threading.Timer(timeout, _watchdog_kill)
|
|
612
750
|
watchdog.daemon = True
|
|
@@ -629,6 +767,7 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
629
767
|
if _current_process is proc:
|
|
630
768
|
_current_process = None
|
|
631
769
|
|
|
770
|
+
t_in.join(timeout=10)
|
|
632
771
|
t_out.join(timeout=10)
|
|
633
772
|
t_err.join(timeout=10)
|
|
634
773
|
|
|
@@ -644,7 +783,7 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
644
783
|
|
|
645
784
|
raw_stdout = stdout_data or ""
|
|
646
785
|
stderr = stderr_data or ""
|
|
647
|
-
output, input_tokens, output_tokens, cost_usd, context_tokens = _parse_stream_json(raw_stdout)
|
|
786
|
+
output, input_tokens, output_tokens, cost_usd, context_tokens, stream_model = _parse_stream_json(raw_stdout)
|
|
648
787
|
output = output.strip()
|
|
649
788
|
ts = time.strftime("%H:%M:%S")
|
|
650
789
|
|
|
@@ -667,14 +806,28 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
|
|
|
667
806
|
f"{name} exited with code {proc.returncode}: {stderr[:300]}"
|
|
668
807
|
)
|
|
669
808
|
|
|
809
|
+
stats_name = display_name or name
|
|
670
810
|
try:
|
|
671
811
|
log_agent_invocation(
|
|
672
|
-
|
|
812
|
+
stats_name, len(prompt), len(output), session_id, working_dir,
|
|
673
813
|
input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=cost_usd,
|
|
674
814
|
)
|
|
675
815
|
except Exception:
|
|
676
816
|
pass
|
|
677
817
|
|
|
818
|
+
try:
|
|
819
|
+
resolved_model = stream_model or model or "claude-sonnet-4-6"
|
|
820
|
+
exec_mode = "api" if cost_usd and cost_usd > 0 else "subscription"
|
|
821
|
+
_project = os.path.basename(working_dir) if working_dir else None
|
|
822
|
+
log_api_cost(
|
|
823
|
+
session_id=session_id, agent_name=stats_name, model=resolved_model,
|
|
824
|
+
input_tokens=input_tokens, output_tokens=output_tokens,
|
|
825
|
+
actual_cost_usd=cost_usd, execution_mode=exec_mode,
|
|
826
|
+
project=_project,
|
|
827
|
+
)
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
|
|
678
831
|
return output, input_tokens, output_tokens, context_tokens
|
|
679
832
|
|
|
680
833
|
|
|
@@ -697,6 +850,8 @@ class Session:
|
|
|
697
850
|
self.auto_commit = auto_commit
|
|
698
851
|
self.blocked_mcp_servers = blocked_mcp_servers or []
|
|
699
852
|
self.auto_committed = False
|
|
853
|
+
self.total_duration_seconds: int = 0
|
|
854
|
+
self._start_monotonic: float = 0.0
|
|
700
855
|
self.timeout = max(5, min(120, timeout_minutes)) * 60
|
|
701
856
|
self.status = "running"
|
|
702
857
|
self.result: Optional[str] = None
|
|
@@ -723,6 +878,8 @@ class Session:
|
|
|
723
878
|
|
|
724
879
|
def _save_state(self) -> None:
|
|
725
880
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
881
|
+
if self.status in ("completed", "error", "stopped") and self.total_duration_seconds == 0 and self._start_monotonic > 0:
|
|
882
|
+
self.total_duration_seconds = round(time.monotonic() - self._start_monotonic)
|
|
726
883
|
data = {
|
|
727
884
|
"session_id": self.session_id,
|
|
728
885
|
"status": self.status,
|
|
@@ -735,6 +892,7 @@ class Session:
|
|
|
735
892
|
"max_iterations": self.max_iterations,
|
|
736
893
|
"result": self.result,
|
|
737
894
|
"auto_committed": self.auto_committed,
|
|
895
|
+
"total_duration_seconds": self.total_duration_seconds,
|
|
738
896
|
}
|
|
739
897
|
try:
|
|
740
898
|
STATE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
@@ -786,6 +944,7 @@ class Session:
|
|
|
786
944
|
"result": self.result,
|
|
787
945
|
"auto_committed": self.auto_committed,
|
|
788
946
|
"commit_summary": commit_summary,
|
|
947
|
+
"total_duration_seconds": self.total_duration_seconds,
|
|
789
948
|
}
|
|
790
949
|
|
|
791
950
|
|
|
@@ -804,17 +963,19 @@ _current_process = None
|
|
|
804
963
|
# ---------------------------------------------------------------------------
|
|
805
964
|
|
|
806
965
|
def _orchestrate(session: Session) -> None:
|
|
807
|
-
"""Run the Manager → Builder → Reviewer loop
|
|
966
|
+
"""Run the Manager → Builder → Reviewer loop."""
|
|
967
|
+
session._start_monotonic = time.monotonic()
|
|
808
968
|
session_log_dir = STATE_DIR / "sessions" / session.session_id
|
|
809
969
|
|
|
810
970
|
try:
|
|
811
|
-
# Load execution modes for this run
|
|
971
|
+
# Load execution modes and custom display names for this run
|
|
812
972
|
agent_modes = load_agent_modes()
|
|
813
|
-
|
|
973
|
+
display_names = _load_agent_display_names()
|
|
974
|
+
has_api_key = bool(_load_api_key() or ANTHROPIC_BASE_URL)
|
|
814
975
|
for _ag in ("manager", "builder", "reviewer"):
|
|
815
976
|
if agent_modes[_ag] == "api" and not has_api_key:
|
|
816
977
|
agent_modes[_ag] = "subscription"
|
|
817
|
-
print(f"[agent-runner] {_ag}: mode=api but no API key
|
|
978
|
+
print(f"[agent-runner] {_ag}: mode=api but no API key found, falling back to subscription", file=sys.stderr)
|
|
818
979
|
|
|
819
980
|
# Step 1: Manager analyzes the task
|
|
820
981
|
session.agents["manager"]["status"] = "busy"
|
|
@@ -839,6 +1000,7 @@ def _orchestrate(session: Session) -> None:
|
|
|
839
1000
|
model=session.models.get("manager", ""),
|
|
840
1001
|
session_id=session.session_id,
|
|
841
1002
|
working_dir=session.working_dir,
|
|
1003
|
+
display_name=display_names["manager"],
|
|
842
1004
|
)
|
|
843
1005
|
m_ctx = m_in
|
|
844
1006
|
else:
|
|
@@ -848,6 +1010,8 @@ def _orchestrate(session: Session) -> None:
|
|
|
848
1010
|
skip_permissions=True, session_id=session.session_id,
|
|
849
1011
|
timeout=min(600, session.timeout),
|
|
850
1012
|
model=session.models.get("manager", ""),
|
|
1013
|
+
display_name=display_names["manager"],
|
|
1014
|
+
execution_mode=agent_modes["manager"],
|
|
851
1015
|
)
|
|
852
1016
|
except subprocess.TimeoutExpired:
|
|
853
1017
|
session.agents["manager"]["status"] = "timeout"
|
|
@@ -912,6 +1076,8 @@ def _orchestrate(session: Session) -> None:
|
|
|
912
1076
|
model=session.models.get("builder", ""),
|
|
913
1077
|
disallowed_tools=session.blocked_mcp_servers,
|
|
914
1078
|
session=session,
|
|
1079
|
+
display_name=display_names["builder"],
|
|
1080
|
+
execution_mode=agent_modes["builder"],
|
|
915
1081
|
)
|
|
916
1082
|
else:
|
|
917
1083
|
builder_output, b_in, b_out = _invoke_api_agent(
|
|
@@ -922,6 +1088,7 @@ def _orchestrate(session: Session) -> None:
|
|
|
922
1088
|
model=session.models.get("builder", ""),
|
|
923
1089
|
session_id=session.session_id,
|
|
924
1090
|
working_dir=session.working_dir,
|
|
1091
|
+
display_name=display_names["builder"],
|
|
925
1092
|
)
|
|
926
1093
|
b_ctx = b_in
|
|
927
1094
|
except subprocess.TimeoutExpired:
|
|
@@ -990,6 +1157,7 @@ def _orchestrate(session: Session) -> None:
|
|
|
990
1157
|
model=session.models.get("reviewer", ""),
|
|
991
1158
|
session_id=session.session_id,
|
|
992
1159
|
working_dir=session.working_dir,
|
|
1160
|
+
display_name=display_names["reviewer"],
|
|
993
1161
|
)
|
|
994
1162
|
r_ctx = r_in
|
|
995
1163
|
else:
|
|
@@ -999,6 +1167,8 @@ def _orchestrate(session: Session) -> None:
|
|
|
999
1167
|
skip_permissions=True, session_id=session.session_id,
|
|
1000
1168
|
timeout=session.timeout,
|
|
1001
1169
|
model=session.models.get("reviewer", ""),
|
|
1170
|
+
display_name=display_names["reviewer"],
|
|
1171
|
+
execution_mode=agent_modes["reviewer"],
|
|
1002
1172
|
)
|
|
1003
1173
|
except subprocess.TimeoutExpired:
|
|
1004
1174
|
session.agents["reviewer"]["status"] = "timeout"
|