memstack-skill-loader 4.10.0__tar.gz → 4.11.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 (51) hide show
  1. {memstack_skill_loader-4.10.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.11.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/pyproject.toml +2 -2
  3. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/agent_runner.py +214 -3
  5. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/dashboard.html +37 -18
  6. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/memory_db.py +161 -16
  7. memstack_skill_loader-4.11.0/src/memstack_skill_loader/skill_descriptions.json +522 -0
  8. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  9. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +4 -0
  10. memstack_skill_loader-4.11.0/tests/test_advisor_strategy.py +195 -0
  11. memstack_skill_loader-4.11.0/tests/test_failed_approaches.py +83 -0
  12. memstack_skill_loader-4.11.0/tests/test_search_memory.py +161 -0
  13. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/MANIFEST.in +0 -0
  14. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/README.md +0 -0
  15. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/setup.cfg +0 -0
  16. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/__main__.py +0 -0
  17. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/categories.py +0 -0
  18. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/compression.py +0 -0
  19. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/config.py +0 -0
  20. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/dashboard.py +0 -0
  21. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/goal.py +0 -0
  22. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/goal_engine.py +0 -0
  23. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
  24. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
  25. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/indexer.py +0 -0
  26. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/license.py +0 -0
  27. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  28. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  29. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  30. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  31. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  32. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  33. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  34. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  35. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/search.py +0 -0
  36. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/server.py +0 -0
  37. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/skill_config.py +0 -0
  38. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/stats.py +0 -0
  39. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  40. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/version_check.py +0 -0
  41. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  42. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  43. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  44. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  45. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_body_parser_metric.py +0 -0
  46. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_code_read_savings.py +0 -0
  47. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_license_environment.py +0 -0
  48. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_license_grace.py +0 -0
  49. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_pro_compressor.py +0 -0
  50. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_pro_skills_update.py +0 -0
  51. {memstack_skill_loader-4.10.0 → memstack_skill_loader-4.11.0}/tests/test_skill_drift.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.10.0
3
+ Version: 4.11.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.10.0"
7
+ version = "4.11.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 = [
@@ -23,4 +23,4 @@ memstack-index = "memstack_skill_loader.indexer:main"
23
23
  where = ["src"]
24
24
 
25
25
  [tool.setuptools.package-data]
26
- memstack_skill_loader = ["dashboard.html"]
26
+ memstack_skill_loader = ["dashboard.html", "skill_descriptions.json"]
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.10.0"
3
+ __version__ = "4.11.0"
@@ -156,6 +156,35 @@ SYSTEM_PROMPTS = {
156
156
  "or update a .env.example file with the variable name and a placeholder value.\n"
157
157
  "- Do NOT run git add, git commit, or any git commands. Only make file changes."
158
158
  ),
159
+ "advisor": (
160
+ "You are an Advisor agent. You are called in ONLY when a Builder agent has failed "
161
+ "three consecutive review cycles on the SAME subtask. The approach being used is not "
162
+ "working and will not start working by trying harder — it needs to change.\n\n"
163
+ "You receive:\n"
164
+ "- The original subtask specification.\n"
165
+ "- The full history of Builder attempts and the Reviewer's REVISE feedback for each.\n\n"
166
+ "Your job:\n"
167
+ "1. DIAGNOSE the root cause. Identify the single recurring reason the attempts keep "
168
+ "failing review — look for the pattern across all attempts, not just the last one.\n"
169
+ "2. PRESCRIBE a DIFFERENT fundamental approach. It MUST be materially different from "
170
+ "what has already been tried — a different file, a different mechanism, a different "
171
+ "sequence, or a different technique. Do NOT restate, rephrase, or lightly tweak the "
172
+ "approach that already failed.\n\n"
173
+ "Hard rules:\n"
174
+ "- Do NOT use tools. Reason only from the text provided.\n"
175
+ "- Do NOT repeat the failed approach. If your recommendation resembles what was already "
176
+ "tried, it is wrong — find another angle.\n"
177
+ "- Be concrete and file-specific. The Builder will act on this directly.\n"
178
+ "- Be concise. This output is injected into the Builder's next prompt.\n\n"
179
+ "Output EXACTLY this structure:\n"
180
+ "ROOT CAUSE: <one or two sentences naming why every attempt failed>\n"
181
+ "DIFFERENT APPROACH: <the new approach, explicitly different from what failed>\n"
182
+ "CONCRETE STEPS:\n"
183
+ "- <step 1>\n"
184
+ "- <step 2>\n"
185
+ "- <step 3 (add more only if needed)>\n"
186
+ "AVOID: <the specific thing the Builder must stop doing>"
187
+ ),
159
188
  "reviewer": (
160
189
  "You are a Reviewer agent. You evaluate the Builder's changes against the original task "
161
190
  "specification using a structured 4-perspective review.\n\n"
@@ -1016,6 +1045,119 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
1016
1045
  return output, total_input_tokens, output_tokens
1017
1046
 
1018
1047
 
1048
+ # ---------------------------------------------------------------------------
1049
+ # Advisor consult (direct API — no file tools; fires after 3 REVISE strikes)
1050
+ # ---------------------------------------------------------------------------
1051
+
1052
+ def _consult_advisor(session: "Session", spec: str, attempts_history: str,
1053
+ log_path: Optional[Path] = None, model: str = "",
1054
+ display_name: str = "Advisor") -> str:
1055
+ """Consult the Advisor after 3 consecutive Reviewer-REVISE strikes on one subtask.
1056
+
1057
+ Runs via the direct Anthropic API (_invoke_api_agent) — no file tools. Returns a
1058
+ formatted intervention block to fold into the next Builder prompt, or "" on any
1059
+ failure so the caller degrades gracefully to normal revision. Advisor token usage
1060
+ is written to advisor.log only; it is intentionally NOT tracked in session.agents.
1061
+ """
1062
+ advisor_input = (
1063
+ f"Subtask specification:\n{spec}\n\n"
1064
+ "The Builder has now failed three consecutive review cycles on this subtask. "
1065
+ "Here is the full history of attempts and the Reviewer's REVISE feedback:\n\n"
1066
+ f"{attempts_history}\n\n"
1067
+ "Diagnose the recurring root cause and prescribe a DIFFERENT fundamental approach."
1068
+ )
1069
+ try:
1070
+ advisor_output, _a_in, _a_out = _invoke_api_agent(
1071
+ "advisor", advisor_input,
1072
+ system_prompt=SYSTEM_PROMPTS["advisor"],
1073
+ log_path=log_path,
1074
+ timeout=session.timeout,
1075
+ model=model,
1076
+ session_id=session.session_id,
1077
+ working_dir=session.working_dir,
1078
+ display_name=display_name,
1079
+ )
1080
+ except Exception as exc:
1081
+ session.add_message("system", "all", f"Advisor consult failed ({exc}); continuing with normal revision")
1082
+ return ""
1083
+ if not (advisor_output or "").strip():
1084
+ return ""
1085
+ # Decision 3: post under the already-registered "system" role, not a new author.
1086
+ session.add_message("system", "builder", advisor_output)
1087
+ return (
1088
+ "=== ADVISOR INTERVENTION (after 3 failed attempts) ===\n"
1089
+ "The previous approach has failed repeatedly. Do NOT repeat it. "
1090
+ "Follow this alternative approach instead:\n"
1091
+ f"{advisor_output}\n"
1092
+ "=== END ADVISOR INTERVENTION ==="
1093
+ )
1094
+
1095
+
1096
+ # ---------------------------------------------------------------------------
1097
+ # Failed-approaches memory (best-effort; never interrupts a run)
1098
+ # ---------------------------------------------------------------------------
1099
+
1100
+ _FA_STORE_SLICE = 1200 # max chars persisted per approach / feedback
1101
+ _FA_SHOW_SLICE = 500 # max chars shown per field in the read-back block
1102
+ _FA_TOPK = 3
1103
+
1104
+
1105
+ def _record_failed_approach(session: "Session", task_text: str,
1106
+ title: Optional[str], spec: Optional[str],
1107
+ builder_output: str, reviewer_output: str,
1108
+ iteration: int) -> None:
1109
+ """Persist one Reviewer-REVISE failure. Best-effort; never raises."""
1110
+ try:
1111
+ from .memory_db import record_failed_approach
1112
+ record_failed_approach(
1113
+ project_dir=session.working_dir,
1114
+ task_text=task_text or "",
1115
+ approach_summary=(builder_output or "")[:_FA_STORE_SLICE],
1116
+ reviewer_feedback=(reviewer_output or "")[:_FA_STORE_SLICE],
1117
+ iteration=iteration,
1118
+ subtask_title=title,
1119
+ subtask_spec=spec,
1120
+ session_id=session.session_id,
1121
+ )
1122
+ except Exception as exc:
1123
+ print(f"[agent-runner] record_failed_approach failed: {exc}", file=sys.stderr)
1124
+
1125
+
1126
+ def _failed_approaches_block(session: "Session", task_text: str,
1127
+ title: Optional[str] = None,
1128
+ spec: Optional[str] = None) -> str:
1129
+ """Return a 'previously failed approaches' block, or '' if none / on error."""
1130
+ try:
1131
+ from .memory_db import query_failed_approaches
1132
+ key = " ".join(p for p in (task_text, title, spec) if p)
1133
+ rows = query_failed_approaches(
1134
+ key, project_dir=session.working_dir, top_k=_FA_TOPK
1135
+ )
1136
+ if not rows:
1137
+ return ""
1138
+ lines = [
1139
+ "=== PREVIOUSLY FAILED APPROACHES ON SIMILAR TASKS ===",
1140
+ "These were tried on similar work and REJECTED by the reviewer. "
1141
+ "Do NOT repeat them; learn from why they failed.",
1142
+ "",
1143
+ ]
1144
+ for i, r in enumerate(rows, 1):
1145
+ t = r.get("subtask_title") or "(whole task)"
1146
+ lines.append(f"[{i}] (attempt {r.get('iteration')}) {t}")
1147
+ lines.append(
1148
+ f" Approach tried: {(r.get('approach_summary') or '')[:_FA_SHOW_SLICE]}"
1149
+ )
1150
+ lines.append(
1151
+ f" Why it failed: {(r.get('reviewer_feedback') or '')[:_FA_SHOW_SLICE]}"
1152
+ )
1153
+ lines.append("")
1154
+ lines.append("=== END PREVIOUSLY FAILED APPROACHES ===")
1155
+ return "\n".join(lines)
1156
+ except Exception as exc:
1157
+ print(f"[agent-runner] failed_approaches lookup failed: {exc}", file=sys.stderr)
1158
+ return ""
1159
+
1160
+
1019
1161
  # ---------------------------------------------------------------------------
1020
1162
  # CC subprocess invocation (Builder — needs file tools)
1021
1163
  # ---------------------------------------------------------------------------
@@ -1532,6 +1674,9 @@ def _orchestrate(session: Session) -> None:
1532
1674
  session.add_message("system", "all", f"Starting subtask {idx} of {len(subtasks)}: {title}")
1533
1675
  builder_input = spec
1534
1676
  subtask_passed = False
1677
+ fail_streak = 0 # consecutive Reviewer-REVISE strikes on THIS subtask
1678
+ advisor_used = False # cap: at most one Advisor consult per subtask
1679
+ attempts_history: list[str] = []
1535
1680
 
1536
1681
  for iteration in range(1, MAX_ITERATIONS + 1):
1537
1682
  if session.status == "stopped":
@@ -1551,9 +1696,15 @@ def _orchestrate(session: Session) -> None:
1551
1696
  session.agents["builder"]["last_output"] = ""
1552
1697
  session._save_state()
1553
1698
 
1699
+ fa_block = ""
1700
+ if iteration == 1:
1701
+ fa_block = _failed_approaches_block(
1702
+ session, session.task, title, spec
1703
+ )
1554
1704
  builder_prompt = (
1555
1705
  SYSTEM_PROMPTS["builder"].format(working_dir=session.working_dir)
1556
1706
  + "\n\n" + context
1707
+ + (("\n\n" + fa_block) if fa_block else "")
1557
1708
  + f"\n\nOriginal task:\n{session.task}"
1558
1709
  + (f"\n\nAdditional context from user:\n{session.context}" if session.context else "")
1559
1710
  + f"\n\nBuild specification:\n{builder_input}"
@@ -1741,10 +1892,37 @@ def _orchestrate(session: Session) -> None:
1741
1892
  session.add_message("system", "all", f"Subtask {idx} of {len(subtasks)} approved: {title}")
1742
1893
  break
1743
1894
 
1895
+ # Reviewer requested changes (REVISE) — a retryable strike. Hard-fails
1896
+ # never reach here; they break out of the iteration loop above.
1897
+ fail_streak += 1
1898
+ attempts_history.append(
1899
+ f"--- Attempt {iteration} ---\n"
1900
+ f"Builder did:\n{(builder_output or '')[:1500]}\n"
1901
+ f"Reviewer REVISE feedback:\n{(reviewer_output or '')[:1500]}"
1902
+ )
1903
+ _record_failed_approach(
1904
+ session, session.task, title, spec,
1905
+ builder_output, reviewer_output, iteration,
1906
+ )
1907
+ advisor_block = ""
1908
+ if fail_streak >= 3 and not advisor_used:
1909
+ advisor_used = True
1910
+ session.add_message(
1911
+ "system", "all",
1912
+ f"Subtask {idx} of {len(subtasks)}: Builder stuck after "
1913
+ f"{fail_streak} attempts — consulting Advisor for a new approach",
1914
+ )
1915
+ advisor_block = _consult_advisor(
1916
+ session, spec, "\n\n".join(attempts_history),
1917
+ log_path=session_log_dir / "advisor.log",
1918
+ model=session.models.get("advisor", ""),
1919
+ display_name=display_names.get("advisor", "Advisor"),
1920
+ )
1744
1921
  builder_input = (
1745
1922
  f"The reviewer requested changes:\n{reviewer_output}\n\n"
1746
1923
  f"Original subtask specification:\n{spec}\n\n"
1747
- "Please address the reviewer's feedback."
1924
+ + (advisor_block + "\n\n" if advisor_block else "")
1925
+ + "Please address the reviewer's feedback."
1748
1926
  )
1749
1927
  else:
1750
1928
  if not subtask_passed:
@@ -1826,6 +2004,9 @@ def _orchestrate(session: Session) -> None:
1826
2004
 
1827
2005
  # Step 2-3: Builder/Reviewer loop
1828
2006
  builder_input = manager_output
2007
+ fail_streak = 0 # consecutive Reviewer-REVISE strikes on this task
2008
+ advisor_used = False # cap: at most one Advisor consult per task
2009
+ attempts_history: list[str] = []
1829
2010
 
1830
2011
  for iteration in range(1, MAX_ITERATIONS + 1):
1831
2012
  if session.status == "stopped":
@@ -1845,9 +2026,13 @@ def _orchestrate(session: Session) -> None:
1845
2026
  session.agents["builder"]["last_output"] = ""
1846
2027
  session._save_state()
1847
2028
 
2029
+ fa_block = ""
2030
+ if iteration == 1:
2031
+ fa_block = _failed_approaches_block(session, session.task)
1848
2032
  builder_prompt = (
1849
2033
  SYSTEM_PROMPTS["builder"].format(working_dir=session.working_dir)
1850
2034
  + "\n\n" + context
2035
+ + (("\n\n" + fa_block) if fa_block else "")
1851
2036
  + f"\n\nOriginal task:\n{session.task}"
1852
2037
  + (f"\n\nAdditional context from user:\n{session.context}" if session.context else "")
1853
2038
  + f"\n\nBuild specification:\n{builder_input}"
@@ -2088,11 +2273,37 @@ def _orchestrate(session: Session) -> None:
2088
2273
  session._save_state()
2089
2274
  return
2090
2275
 
2091
- # Changes requested — feed back to builder
2276
+ # Changes requested — feed back to builder. Retryable REVISE strike;
2277
+ # hard-fails return out above and never reach here.
2278
+ fail_streak += 1
2279
+ attempts_history.append(
2280
+ f"--- Attempt {iteration} ---\n"
2281
+ f"Builder did:\n{(builder_output or '')[:1500]}\n"
2282
+ f"Reviewer REVISE feedback:\n{(reviewer_output or '')[:1500]}"
2283
+ )
2284
+ _record_failed_approach(
2285
+ session, session.task, None, None,
2286
+ builder_output, reviewer_output, iteration,
2287
+ )
2288
+ advisor_block = ""
2289
+ if fail_streak >= 3 and not advisor_used:
2290
+ advisor_used = True
2291
+ session.add_message(
2292
+ "system", "all",
2293
+ f"Builder stuck after {fail_streak} attempts — "
2294
+ "consulting Advisor for a new approach",
2295
+ )
2296
+ advisor_block = _consult_advisor(
2297
+ session, manager_output, "\n\n".join(attempts_history),
2298
+ log_path=session_log_dir / "advisor.log",
2299
+ model=session.models.get("advisor", ""),
2300
+ display_name=display_names.get("advisor", "Advisor"),
2301
+ )
2092
2302
  builder_input = (
2093
2303
  f"The reviewer requested changes:\n{reviewer_output}\n\n"
2094
2304
  f"Original manager specification:\n{manager_output}\n\n"
2095
- "Please address the reviewer's feedback."
2305
+ + (advisor_block + "\n\n" if advisor_block else "")
2306
+ + "Please address the reviewer's feedback."
2096
2307
  )
2097
2308
 
2098
2309
  session.status = "completed"
@@ -3599,7 +3599,7 @@ async function resetAgentUI() {
3599
3599
  // Restore working directory from localStorage (user likely wants same project)
3600
3600
  const cachedWd = localStorage.getItem('memstack-last-workdir');
3601
3601
  const wdInput = document.getElementById('agent-workdir-input');
3602
- if (cachedWd) { wdInput.value = cachedWd; fetchMcpServers(cachedWd); fetchBranches(cachedWd); fetchAgentWorkflow(cachedWd); }
3602
+ if (cachedWd) { wdInput.value = cachedWd; refreshWorkdir(cachedWd); }
3603
3603
  }
3604
3604
 
3605
3605
  async function startAgentTask() {
@@ -3636,12 +3636,16 @@ async function startAgentTask() {
3636
3636
  await fetchBranches(workDir);
3637
3637
  }
3638
3638
  }
3639
+ // Repopulate the branch dropdown from the ACTUAL working dir on every run
3640
+ // start (not only the git-init path above), so the options, pre-selected
3641
+ // branch, and dataset.currentBranch reflect this repo — never a stale
3642
+ // dropdown left over from a previously-loaded directory.
3643
+ if (workDir) await fetchBranches(workDir);
3639
3644
  const branchSel = document.getElementById('agent-branch-select');
3640
3645
  const branchRow = document.getElementById('agent-branch-row');
3641
3646
  const selectedBranch = (branchRow && branchRow.style.display !== 'none' && branchSel && branchSel.value) ? branchSel.value : '';
3642
3647
  if (selectedBranch && workDir) {
3643
- const curOpt = branchSel.querySelector('option[selected]');
3644
- const currentBranch = curOpt ? curOpt.value : '';
3648
+ const currentBranch = branchSel.dataset.currentBranch || '';
3645
3649
  if (selectedBranch !== currentBranch) {
3646
3650
  btn.textContent = 'Checking out branch...';
3647
3651
  const coRes = await fetch('/api/git-checkout', {method:'POST', headers: AUTH_HEADERS, body: JSON.stringify({workdir: workDir, branch: selectedBranch})});
@@ -3720,9 +3724,17 @@ async function loadAgentMonitor() {
3720
3724
  wdInput._mcpBound = true;
3721
3725
  wdInput.addEventListener('blur', () => {
3722
3726
  const wd = wdInput.value.trim();
3723
- fetchMcpServers(wd);
3724
- fetchBranches(wd);
3725
- fetchAgentWorkflow(wd);
3727
+ refreshWorkdir(wd);
3728
+ });
3729
+ // Also refresh when the user types/pastes a new path (debounced), so the
3730
+ // branch dropdown updates without waiting for the field to lose focus.
3731
+ let _wdInputTimer = null;
3732
+ wdInput.addEventListener('input', () => {
3733
+ clearTimeout(_wdInputTimer);
3734
+ _wdInputTimer = setTimeout(() => {
3735
+ const wd = wdInput.value.trim();
3736
+ refreshWorkdir(wd);
3737
+ }, 400);
3726
3738
  });
3727
3739
  }
3728
3740
  fetchAgentStatus();
@@ -3734,11 +3746,6 @@ async function loadAgentMonitor() {
3734
3746
  }
3735
3747
 
3736
3748
  async function loadLastWorkdir() {
3737
- function _initWorkdir(path) {
3738
- fetchMcpServers(path);
3739
- fetchBranches(path);
3740
- fetchAgentWorkflow(path);
3741
- }
3742
3749
  try {
3743
3750
  const input = document.getElementById('agent-workdir-input');
3744
3751
  if (input.value.trim()) return;
@@ -3747,16 +3754,16 @@ async function loadLastWorkdir() {
3747
3754
  if (data.path) {
3748
3755
  input.value = data.path;
3749
3756
  localStorage.setItem('memstack-last-workdir', data.path);
3750
- _initWorkdir(data.path);
3757
+ refreshWorkdir(data.path);
3751
3758
  } else {
3752
3759
  const cached = localStorage.getItem('memstack-last-workdir');
3753
- if (cached) { input.value = cached; _initWorkdir(cached); }
3760
+ if (cached) { input.value = cached; refreshWorkdir(cached); }
3754
3761
  }
3755
3762
  } catch(e) {
3756
3763
  const cached = localStorage.getItem('memstack-last-workdir');
3757
3764
  if (cached) {
3758
3765
  const input = document.getElementById('agent-workdir-input');
3759
- if (!input.value.trim()) { input.value = cached; _initWorkdir(cached); }
3766
+ if (!input.value.trim()) { input.value = cached; refreshWorkdir(cached); }
3760
3767
  }
3761
3768
  }
3762
3769
  }
@@ -3772,6 +3779,12 @@ function updateMcpToolsSummaryLabel() {
3772
3779
  el.textContent = name + ' MCP Tools';
3773
3780
  }
3774
3781
 
3782
+ function refreshWorkdir(path) {
3783
+ fetchMcpServers(path);
3784
+ fetchBranches(path);
3785
+ fetchAgentWorkflow(path);
3786
+ }
3787
+
3775
3788
  async function fetchMcpServers(workdir) {
3776
3789
  const listEl = document.getElementById('mcp-tools-list');
3777
3790
  const emptyEl = document.getElementById('mcp-tools-empty');
@@ -3953,9 +3966,9 @@ async function fetchBranches(workdir) {
3953
3966
  if (!workdir) { row.style.display = 'none'; sel.innerHTML = ''; return; }
3954
3967
  try {
3955
3968
  const res = await fetch('/api/git-branches?workdir=' + encodeURIComponent(workdir), {headers: AUTH_GET});
3956
- if (!res.ok) { row.style.display = 'none'; return; }
3969
+ if (!res.ok) { sel.innerHTML = ''; row.style.display = 'none'; return; }
3957
3970
  const d = await res.json();
3958
- if (!d.branches || !d.branches.length) { row.style.display = 'none'; return; }
3971
+ if (!d.branches || !d.branches.length) { sel.innerHTML = ''; row.style.display = 'none'; return; }
3959
3972
  sel.innerHTML = '';
3960
3973
  for (const b of d.branches) {
3961
3974
  const opt = document.createElement('option');
@@ -3963,8 +3976,14 @@ async function fetchBranches(workdir) {
3963
3976
  if (b === d.current) opt.selected = true;
3964
3977
  sel.appendChild(opt);
3965
3978
  }
3979
+ // Pre-select the repo's actual current branch and record it so the run
3980
+ // path can read the selection reliably (branchSel.value) and compare it
3981
+ // against the true current branch (dataset), not a [selected] attribute
3982
+ // that opt.selected = true never sets.
3983
+ if (d.current) sel.value = d.current;
3984
+ sel.dataset.currentBranch = d.current || '';
3966
3985
  row.style.display = 'block';
3967
- } catch(e) { row.style.display = 'none'; }
3986
+ } catch(e) { sel.innerHTML = ''; row.style.display = 'none'; }
3968
3987
  }
3969
3988
 
3970
3989
  async function fetchAgentWorkflow(workdir) {
@@ -4317,7 +4336,7 @@ function closeDirBrowser() {
4317
4336
  function selectDirBrowser() {
4318
4337
  document.getElementById('agent-workdir-input').value = dirBrowserCurrentPath;
4319
4338
  closeDirBrowser();
4320
- fetchMcpServers(dirBrowserCurrentPath);
4339
+ refreshWorkdir(dirBrowserCurrentPath);
4321
4340
  }
4322
4341
 
4323
4342
  function dirBrowserBack() {
@@ -11,9 +11,22 @@ DB_PATH = Path.home() / ".memstack" / "memory.db"
11
11
  SCHEMA_VERSION = 1
12
12
 
13
13
 
14
+ def _resolve_db_path() -> Path:
15
+ """Resolve the memory DB path at call time.
16
+
17
+ Honors the ``MEMSTACK_DB_PATH`` environment override (used by tests to
18
+ redirect writes away from the real ~/.memstack/memory.db); otherwise falls
19
+ back to the module-level ``DB_PATH`` default. Behavior is identical to the
20
+ old hardcoded path when the env var is absent.
21
+ """
22
+ override = os.environ.get("MEMSTACK_DB_PATH")
23
+ return Path(override) if override else DB_PATH
24
+
25
+
14
26
  def _get_conn() -> sqlite3.Connection:
15
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
16
- conn = sqlite3.connect(str(DB_PATH), timeout=5)
27
+ db_path = _resolve_db_path()
28
+ db_path.parent.mkdir(parents=True, exist_ok=True)
29
+ conn = sqlite3.connect(str(db_path), timeout=5)
17
30
  conn.row_factory = sqlite3.Row
18
31
  conn.execute("PRAGMA journal_mode=WAL")
19
32
  _init_tables(conn)
@@ -124,6 +137,36 @@ def _init_tables(conn: sqlite3.Connection) -> None:
124
137
  """
125
138
  )
126
139
 
140
+ cur.execute(
141
+ """
142
+ CREATE TABLE IF NOT EXISTS failed_approaches (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ project_dir TEXT NOT NULL,
145
+ session_id TEXT,
146
+ task_text TEXT NOT NULL,
147
+ subtask_title TEXT,
148
+ subtask_spec TEXT,
149
+ approach_summary TEXT NOT NULL,
150
+ reviewer_feedback TEXT NOT NULL,
151
+ iteration INTEGER,
152
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
153
+ )
154
+ """
155
+ )
156
+ cur.execute(
157
+ "CREATE INDEX IF NOT EXISTS idx_failed_project "
158
+ "ON failed_approaches(project_dir)"
159
+ )
160
+ cur.execute(
161
+ """
162
+ CREATE VIRTUAL TABLE IF NOT EXISTS failed_approaches_fts USING fts5(
163
+ match_text,
164
+ source_id,
165
+ project_dir
166
+ )
167
+ """
168
+ )
169
+
127
170
  conn.commit()
128
171
 
129
172
 
@@ -228,23 +271,31 @@ def insert_lesson(
228
271
  project_dir: str | None = None,
229
272
  global_: bool = False,
230
273
  stack_tags: list[str] | None = None,
274
+ *,
275
+ created_at: str | None = None,
231
276
  ) -> int | None:
232
277
  try:
233
278
  conn = _get_conn()
234
279
  try:
235
280
  pdir = None if global_ else project_dir
236
- cur = conn.execute(
237
- "INSERT INTO procedural_memory "
238
- "(project_dir, category, title, content, stack_tags) "
239
- "VALUES (?, ?, ?, ?, ?)",
240
- (
241
- pdir,
242
- category,
243
- title,
244
- content,
245
- json.dumps(stack_tags) if stack_tags else None,
246
- ),
247
- )
281
+ tags_json = json.dumps(stack_tags) if stack_tags else None
282
+ if created_at:
283
+ # Caller-supplied timestamp: bind it (never string-format it in).
284
+ cur = conn.execute(
285
+ "INSERT INTO procedural_memory "
286
+ "(project_dir, category, title, content, stack_tags, created_at) "
287
+ "VALUES (?, ?, ?, ?, ?, ?)",
288
+ (pdir, category, title, content, tags_json, created_at),
289
+ )
290
+ else:
291
+ # Default path: omit created_at so the column DEFAULT
292
+ # (datetime('now')) applies. Byte-identical to prior behavior.
293
+ cur = conn.execute(
294
+ "INSERT INTO procedural_memory "
295
+ "(project_dir, category, title, content, stack_tags) "
296
+ "VALUES (?, ?, ?, ?, ?)",
297
+ (pdir, category, title, content, tags_json),
298
+ )
248
299
  row_id = cur.lastrowid
249
300
  _index_fts(conn, "procedural_memory", row_id,
250
301
  f"{title} {content}", pdir or "")
@@ -298,6 +349,98 @@ def insert_fact(
298
349
  return None
299
350
 
300
351
 
352
+ # ---------------------------------------------------------------------------
353
+ # Failed-approaches memory
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def record_failed_approach(
357
+ project_dir: str,
358
+ task_text: str,
359
+ approach_summary: str,
360
+ reviewer_feedback: str,
361
+ iteration: int,
362
+ subtask_title: str | None = None,
363
+ subtask_spec: str | None = None,
364
+ session_id: str | None = None,
365
+ ) -> int | None:
366
+ try:
367
+ conn = _get_conn()
368
+ try:
369
+ cur = conn.execute(
370
+ "INSERT INTO failed_approaches "
371
+ "(project_dir, session_id, task_text, subtask_title, subtask_spec, "
372
+ "approach_summary, reviewer_feedback, iteration) "
373
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
374
+ (project_dir, session_id, task_text, subtask_title, subtask_spec,
375
+ approach_summary, reviewer_feedback, iteration),
376
+ )
377
+ row_id = cur.lastrowid
378
+ match_text = " ".join(
379
+ p for p in (task_text, subtask_title, subtask_spec) if p
380
+ )
381
+ conn.execute(
382
+ "INSERT INTO failed_approaches_fts (match_text, source_id, project_dir) "
383
+ "VALUES (?, ?, ?)",
384
+ (match_text, str(row_id), project_dir),
385
+ )
386
+ conn.commit()
387
+ return row_id
388
+ finally:
389
+ conn.close()
390
+ except Exception as exc:
391
+ print(f"[memstack-memory] record_failed_approach failed: {exc}", file=sys.stderr)
392
+ return None
393
+
394
+
395
+ def _fts_or_query(text: str) -> str:
396
+ """Free text -> safe FTS5 OR-query of unique alphanumeric tokens.
397
+
398
+ Strips punctuation (which FTS5 would treat as query syntax and raise on),
399
+ drops tokens shorter than 3 chars, dedupes, and caps length. Returns "" if
400
+ no usable tokens remain so callers can skip the MATCH entirely.
401
+ """
402
+ import re
403
+ tokens = re.findall(r"[A-Za-z0-9]+", (text or "").lower())
404
+ seen: list[str] = []
405
+ for t in tokens:
406
+ if len(t) >= 3 and t not in seen:
407
+ seen.append(t)
408
+ return " OR ".join(seen[:40])
409
+
410
+
411
+ def query_failed_approaches(
412
+ task_text: str, project_dir: str | None = None, top_k: int = 3
413
+ ) -> list[dict]:
414
+ if top_k > 50:
415
+ top_k = 50
416
+ match = _fts_or_query(task_text)
417
+ if not match:
418
+ return []
419
+ try:
420
+ conn = _get_conn()
421
+ try:
422
+ sql = (
423
+ "SELECT f.id, f.subtask_title, f.subtask_spec, f.approach_summary, "
424
+ "f.reviewer_feedback, f.iteration, f.created_at "
425
+ "FROM failed_approaches_fts fa "
426
+ "JOIN failed_approaches f ON f.id = CAST(fa.source_id AS INTEGER) "
427
+ "WHERE failed_approaches_fts MATCH ?"
428
+ )
429
+ params: list = [match]
430
+ if project_dir:
431
+ sql += " AND fa.project_dir = ?"
432
+ params.append(project_dir)
433
+ sql += " ORDER BY fa.rank LIMIT ?"
434
+ params.append(top_k)
435
+ rows = conn.execute(sql, params).fetchall()
436
+ return [dict(r) for r in rows]
437
+ finally:
438
+ conn.close()
439
+ except Exception as exc:
440
+ print(f"[memstack-memory] query_failed_approaches failed: {exc}", file=sys.stderr)
441
+ return []
442
+
443
+
301
444
  # ---------------------------------------------------------------------------
302
445
  # Query functions
303
446
  # ---------------------------------------------------------------------------
@@ -307,7 +450,9 @@ def search_memory(
307
450
  ) -> list[dict]:
308
451
  if limit > 500:
309
452
  limit = 500
310
- query = f'"{query}"'
453
+ match = _fts_or_query(query)
454
+ if not match:
455
+ return []
311
456
  try:
312
457
  conn = _get_conn()
313
458
  try:
@@ -315,7 +460,7 @@ def search_memory(
315
460
  "SELECT content, source_table, source_id, project_dir, "
316
461
  "rank FROM memory_fts WHERE memory_fts MATCH ?"
317
462
  )
318
- params: list = [query]
463
+ params: list = [match]
319
464
  if project_dir:
320
465
  sql += " AND project_dir = ?"
321
466
  params.append(project_dir)