memstack-skill-loader 4.0.6__tar.gz → 4.1.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.
Files changed (28) hide show
  1. {memstack_skill_loader-4.0.6/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.1.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/agent_runner.py +176 -33
  5. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/dashboard.html +498 -69
  6. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/dashboard.py +495 -76
  7. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/stats.py +57 -1
  8. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  9. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/MANIFEST.in +0 -0
  10. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/README.md +0 -0
  11. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/setup.cfg +0 -0
  12. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/__main__.py +0 -0
  13. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/categories.py +0 -0
  14. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/compression.py +0 -0
  15. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/config.py +0 -0
  16. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/indexer.py +0 -0
  17. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/license.py +0 -0
  18. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/memory_db.py +0 -0
  19. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/search.py +0 -0
  20. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/server.py +0 -0
  21. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/skill_config.py +0 -0
  22. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  23. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader/version_check.py +0 -0
  24. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  25. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  26. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  27. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  28. {memstack_skill_loader-4.0.6 → memstack_skill_loader-4.1.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.0.6
3
+ Version: 4.1.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.0.6"
7
+ version = "4.1.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 = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.0.6"
3
+ __version__ = "4.1.0"
@@ -28,9 +28,15 @@ AGENT_TIMEOUT = 3600 # seconds per --print invocation (default 60 minutes)
28
28
  MAX_ITERATIONS = 2
29
29
 
30
30
  ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "")
31
+ API_KEY_FILE = Path.home() / ".memstack" / "api_key"
31
32
  API_DEFAULT_MODEL = "claude-sonnet-4-20250514"
32
33
  API_MAX_TOKENS = 16000
33
34
 
35
+ if not os.environ.get("ANTHROPIC_API_KEY") and API_KEY_FILE.is_file():
36
+ _stored_key = API_KEY_FILE.read_text(encoding="utf-8").strip()
37
+ if _stored_key:
38
+ os.environ["ANTHROPIC_API_KEY"] = _stored_key
39
+
34
40
  SYSTEM_PROMPTS = {
35
41
  "manager": (
36
42
  "You are a Manager agent. You receive a task and project context (directory listing "
@@ -109,30 +115,105 @@ def discover_mcp_servers(workdir: str) -> list[str]:
109
115
  return []
110
116
 
111
117
 
118
+ _VALID_GIT_WORKFLOWS = ("auto", "single_branch", "dev_to_default", "feature_branch")
119
+
120
+
121
+ def _migrate_config(cfg: dict) -> dict:
122
+ """Migrate per-project values from plain arrays to objects with blocked_mcp key."""
123
+ changed = False
124
+ for key, val in list(cfg.items()):
125
+ if key.startswith("_"):
126
+ continue
127
+ if isinstance(val, list):
128
+ cfg[key] = {"blocked_mcp": val}
129
+ changed = True
130
+ if changed:
131
+ save_builder_tools_config(cfg)
132
+ return cfg
133
+
134
+
112
135
  def load_builder_tools_config() -> dict:
113
- """Load per-project blocked MCP server lists from ~/.memstack/builder-tools-config.json."""
136
+ """Load per-project config from ~/.memstack/builder-tools-config.json, migrating if needed."""
114
137
  try:
115
138
  if BUILDER_TOOLS_CONFIG_FILE.is_file():
116
- return json.loads(BUILDER_TOOLS_CONFIG_FILE.read_text(encoding="utf-8"))
139
+ cfg = json.loads(BUILDER_TOOLS_CONFIG_FILE.read_text(encoding="utf-8"))
140
+ return _migrate_config(cfg)
117
141
  except Exception:
118
142
  pass
119
143
  return {}
120
144
 
121
145
 
122
146
  def save_builder_tools_config(config: dict) -> None:
123
- """Save per-project blocked MCP server config."""
147
+ """Save per-project config."""
124
148
  BUILDER_TOOLS_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
125
149
  BUILDER_TOOLS_CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
126
150
 
127
151
 
152
+ def _get_project_config(cfg: dict, workdir: str) -> dict:
153
+ """Return the project config dict for workdir, or empty dict."""
154
+ val = cfg.get(workdir)
155
+ if isinstance(val, dict):
156
+ return val
157
+ return {}
158
+
159
+
128
160
  def get_blocked_servers_for_project(workdir: str) -> list[str]:
129
161
  """Return blocked MCP servers for a project, falling back to global defaults."""
130
162
  cfg = load_builder_tools_config()
131
- if workdir in cfg:
132
- return cfg[workdir]
163
+ proj = _get_project_config(cfg, workdir)
164
+ if proj:
165
+ return proj.get("blocked_mcp", [])
133
166
  return cfg.get("_global_defaults", [])
134
167
 
135
168
 
169
+ def get_git_workflow_for_project(workdir: str) -> tuple[str, bool]:
170
+ """Return (workflow, is_project_config) for a working directory."""
171
+ cfg = load_builder_tools_config()
172
+ proj = _get_project_config(cfg, workdir)
173
+ if proj and "git_workflow" in proj:
174
+ wf = proj["git_workflow"]
175
+ if wf in _VALID_GIT_WORKFLOWS:
176
+ return wf, True
177
+ global_wf = cfg.get("_git_workflow", "auto")
178
+ if global_wf not in _VALID_GIT_WORKFLOWS:
179
+ global_wf = "auto"
180
+ return global_wf, False
181
+
182
+
183
+ _VALID_AGENT_MODES = ("api", "subscription")
184
+
185
+
186
+ def _default_agent_modes() -> dict:
187
+ """Return default execution modes based on whether an API key is available."""
188
+ has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or ANTHROPIC_BASE_URL)
189
+ if has_key:
190
+ return {"manager": "api", "builder": "subscription", "reviewer": "api"}
191
+ return {"manager": "subscription", "builder": "subscription", "reviewer": "subscription"}
192
+
193
+
194
+ def load_agent_modes() -> dict:
195
+ """Load agent execution modes from config, falling back to defaults."""
196
+ cfg = load_builder_tools_config()
197
+ stored = cfg.get("_agent_modes", {})
198
+ defaults = _default_agent_modes()
199
+ modes = {}
200
+ for agent in ("manager", "builder", "reviewer"):
201
+ val = stored.get(agent, "")
202
+ modes[agent] = val if val in _VALID_AGENT_MODES else defaults[agent]
203
+ return modes
204
+
205
+
206
+ def save_agent_modes(modes: dict) -> None:
207
+ """Save agent execution modes to config."""
208
+ cfg = load_builder_tools_config()
209
+ clean = {}
210
+ for agent in ("manager", "builder", "reviewer"):
211
+ val = modes.get(agent, "")
212
+ clean[agent] = val if val in _VALID_AGENT_MODES else "subscription"
213
+ cfg["_agent_modes"] = clean
214
+ save_builder_tools_config(cfg)
215
+
216
+
136
217
  # ---------------------------------------------------------------------------
137
218
  # Project context gathering
138
219
  # ---------------------------------------------------------------------------
@@ -190,6 +271,24 @@ def _extract_task_text(raw: str) -> str:
190
271
  return result[:72]
191
272
 
192
273
 
274
+ def _clean_task_description(task: str) -> str:
275
+ """Strip Working directory/Branch boilerplate, returning the user's task description."""
276
+ import re
277
+ _BOILERPLATE_LINE = re.compile(
278
+ r'^(working\s+directory:|branch:|read\s+all\s+files\s+before\s+modifying\.?)',
279
+ re.IGNORECASE
280
+ )
281
+ lines = task.splitlines()
282
+ start = 0
283
+ for i, line in enumerate(lines):
284
+ stripped = line.strip()
285
+ if not stripped or _BOILERPLATE_LINE.match(stripped):
286
+ start = i + 1
287
+ else:
288
+ break
289
+ return "\n".join(lines[start:]).strip()
290
+
291
+
193
292
  def _extract_commit_from_reviewer(reviewer_output: str) -> str:
194
293
  """Extract a commit-friendly summary from the reviewer's APPROVED message."""
195
294
  import re
@@ -334,6 +433,7 @@ def _extract_text_from_stream_line(line: str) -> Optional[str]:
334
433
  def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
335
434
  log_path: Optional[Path] = None, timeout: int = 600,
336
435
  model: str = "", session_id: Optional[str] = None,
436
+ working_dir: str = "",
337
437
  ) -> tuple[str, int, int]:
338
438
  """Call the Anthropic Messages API directly via httpx.
339
439
 
@@ -411,7 +511,7 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
411
511
 
412
512
  try:
413
513
  log_agent_invocation(
414
- name, len(prompt), len(output), session_id, "",
514
+ name, len(prompt), len(output), session_id, working_dir,
415
515
  input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=0.0,
416
516
  )
417
517
  except Exception:
@@ -646,6 +746,7 @@ class Session:
646
746
  meta = {
647
747
  "session_id": self.session_id,
648
748
  "task": self.task,
749
+ "task_description": _clean_task_description(self.task),
649
750
  "status": self.status,
650
751
  "started_at": self.started_at,
651
752
  "iteration": self.iteration,
@@ -707,6 +808,14 @@ def _orchestrate(session: Session) -> None:
707
808
  session_log_dir = STATE_DIR / "sessions" / session.session_id
708
809
 
709
810
  try:
811
+ # Load execution modes for this run
812
+ agent_modes = load_agent_modes()
813
+ has_api_key = bool(os.environ.get("ANTHROPIC_API_KEY") or ANTHROPIC_BASE_URL)
814
+ for _ag in ("manager", "builder", "reviewer"):
815
+ if agent_modes[_ag] == "api" and not has_api_key:
816
+ agent_modes[_ag] = "subscription"
817
+ print(f"[agent-runner] {_ag}: mode=api but no API key set, falling back to subscription", file=sys.stderr)
818
+
710
819
  # Step 1: Manager analyzes the task
711
820
  session.agents["manager"]["status"] = "busy"
712
821
  session.agents["manager"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
@@ -721,14 +830,25 @@ def _orchestrate(session: Session) -> None:
721
830
  if session.user_name:
722
831
  manager_prompt = f"The user's name is {session.user_name}.\n\n" + manager_prompt
723
832
  try:
724
- manager_output, m_in, m_out = _invoke_api_agent(
725
- "manager", manager_prompt,
726
- system_prompt=SYSTEM_PROMPTS["manager"],
727
- log_path=session_log_dir / "manager.log",
728
- timeout=min(600, session.timeout),
729
- model=session.models.get("manager", ""),
730
- session_id=session.session_id,
731
- )
833
+ if agent_modes["manager"] == "api":
834
+ manager_output, m_in, m_out = _invoke_api_agent(
835
+ "manager", manager_prompt,
836
+ system_prompt=SYSTEM_PROMPTS["manager"],
837
+ log_path=session_log_dir / "manager.log",
838
+ timeout=min(600, session.timeout),
839
+ model=session.models.get("manager", ""),
840
+ session_id=session.session_id,
841
+ working_dir=session.working_dir,
842
+ )
843
+ m_ctx = m_in
844
+ else:
845
+ manager_output, m_in, m_out, m_ctx = _invoke_agent(
846
+ "manager", manager_prompt, session.working_dir,
847
+ log_path=session_log_dir / "manager.log",
848
+ skip_permissions=True, session_id=session.session_id,
849
+ timeout=min(600, session.timeout),
850
+ model=session.models.get("manager", ""),
851
+ )
732
852
  except subprocess.TimeoutExpired:
733
853
  session.agents["manager"]["status"] = "timeout"
734
854
  session.status = "error"
@@ -743,7 +863,7 @@ def _orchestrate(session: Session) -> None:
743
863
  return
744
864
  session.agents["manager"]["input_tokens"] += m_in
745
865
  session.agents["manager"]["output_tokens"] += m_out
746
- session.agents["manager"]["context_tokens"] = m_in
866
+ session.agents["manager"]["context_tokens"] = m_ctx
747
867
  session.agents["manager"]["last_output"] = (manager_output or "")[:500]
748
868
 
749
869
  session.agents["manager"]["status"] = "done"
@@ -783,15 +903,27 @@ def _orchestrate(session: Session) -> None:
783
903
  if session.user_name:
784
904
  builder_prompt = f"The user's name is {session.user_name}.\n\n" + builder_prompt
785
905
  try:
786
- builder_output, b_in, b_out, b_ctx = _invoke_agent(
787
- "builder", builder_prompt, session.working_dir,
788
- log_path=session_log_dir / "builder.log",
789
- skip_permissions=True, session_id=session.session_id,
790
- timeout=session.timeout,
791
- model=session.models.get("builder", ""),
792
- disallowed_tools=session.blocked_mcp_servers,
793
- session=session,
794
- )
906
+ if agent_modes["builder"] == "subscription":
907
+ builder_output, b_in, b_out, b_ctx = _invoke_agent(
908
+ "builder", builder_prompt, session.working_dir,
909
+ log_path=session_log_dir / "builder.log",
910
+ skip_permissions=True, session_id=session.session_id,
911
+ timeout=session.timeout,
912
+ model=session.models.get("builder", ""),
913
+ disallowed_tools=session.blocked_mcp_servers,
914
+ session=session,
915
+ )
916
+ else:
917
+ builder_output, b_in, b_out = _invoke_api_agent(
918
+ "builder", builder_prompt,
919
+ system_prompt=SYSTEM_PROMPTS["builder"].format(working_dir=session.working_dir),
920
+ log_path=session_log_dir / "builder.log",
921
+ timeout=session.timeout,
922
+ model=session.models.get("builder", ""),
923
+ session_id=session.session_id,
924
+ working_dir=session.working_dir,
925
+ )
926
+ b_ctx = b_in
795
927
  except subprocess.TimeoutExpired:
796
928
  session.agents["builder"]["status"] = "timeout"
797
929
  session.status = "error"
@@ -849,14 +981,25 @@ def _orchestrate(session: Session) -> None:
849
981
  + f"\n\nBuilder output (iteration {iteration}):\n{builder_output}"
850
982
  )
851
983
  try:
852
- reviewer_output, r_in, r_out = _invoke_api_agent(
853
- "reviewer", reviewer_prompt,
854
- system_prompt=SYSTEM_PROMPTS["reviewer"],
855
- log_path=session_log_dir / "reviewer.log",
856
- timeout=session.timeout,
857
- model=session.models.get("reviewer", ""),
858
- session_id=session.session_id,
859
- )
984
+ if agent_modes["reviewer"] == "api":
985
+ reviewer_output, r_in, r_out = _invoke_api_agent(
986
+ "reviewer", reviewer_prompt,
987
+ system_prompt=SYSTEM_PROMPTS["reviewer"],
988
+ log_path=session_log_dir / "reviewer.log",
989
+ timeout=session.timeout,
990
+ model=session.models.get("reviewer", ""),
991
+ session_id=session.session_id,
992
+ working_dir=session.working_dir,
993
+ )
994
+ r_ctx = r_in
995
+ else:
996
+ reviewer_output, r_in, r_out, r_ctx = _invoke_agent(
997
+ "reviewer", reviewer_prompt, session.working_dir,
998
+ log_path=session_log_dir / "reviewer.log",
999
+ skip_permissions=True, session_id=session.session_id,
1000
+ timeout=session.timeout,
1001
+ model=session.models.get("reviewer", ""),
1002
+ )
860
1003
  except subprocess.TimeoutExpired:
861
1004
  session.agents["reviewer"]["status"] = "timeout"
862
1005
  session.status = "error"
@@ -871,7 +1014,7 @@ def _orchestrate(session: Session) -> None:
871
1014
  return
872
1015
  session.agents["reviewer"]["input_tokens"] += r_in
873
1016
  session.agents["reviewer"]["output_tokens"] += r_out
874
- session.agents["reviewer"]["context_tokens"] = r_in
1017
+ session.agents["reviewer"]["context_tokens"] = r_ctx
875
1018
  session.agents["reviewer"]["last_output"] = (reviewer_output or "")[:500]
876
1019
 
877
1020
  session.agents["reviewer"]["status"] = "done"