@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -4,30 +4,127 @@ Zero-API-Key Web Search Engine for IUMBTEMS.
4
4
  DuckDuckGo HTML/lite parser with domain filtering and Claude Code XML formatting.
5
5
  """
6
6
 
7
- import sys
8
7
  import json
9
8
  import re
10
- import urllib.request
9
+ import sys
11
10
  import urllib.parse
12
- from typing import List, Dict, Optional
11
+ import urllib.request
12
+ from html.parser import HTMLParser
13
+ from typing import Dict, List, Optional, Tuple
14
+ from xml.sax.saxutils import escape as xml_escape
13
15
 
14
16
  USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
15
17
 
16
- def search_duckduckgo(
18
+
19
+ class DDGLiteParser(HTMLParser):
20
+ """Linear, non-backtracking HTML parser for DuckDuckGo Lite search results.
21
+
22
+ DuckDuckGo Lite emits one table row per result: an ``<a class="result-link">``
23
+ holding the title and a ``<td class="result-snippet">`` holding the summary.
24
+ Results are assembled **per row** so that:
25
+
26
+ * a missing link or snippet cannot desynchronise every later result (the old
27
+ ``zip(links, snippets)`` pairing shifted all subsequent rows), and
28
+ * nested markup inside a snippet — including a nested
29
+ ``<a class="result-link">`` — never hijacks capture state (the old parser
30
+ reset its buffer mid-snippet and then never recorded the snippet at all).
31
+
32
+ Captured text is stored decoded (``convert_charrefs`` default); escaping is
33
+ done at emit time by :func:`format_xml`.
34
+ """
35
+
36
+ def __init__(self):
37
+ super().__init__()
38
+ self.results: List[Dict[str, str]] = []
39
+ self._row_href: Optional[str] = None
40
+ self._row_title: List[str] = []
41
+ self._row_snippet: List[str] = []
42
+ self._capture: Optional[str] = None # "title" | "snippet" | None
43
+ self._depth = 0 # nesting depth of the tag that opened the capture
44
+
45
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]):
46
+ attr_dict = {k: v or "" for k, v in attrs}
47
+ classes = attr_dict.get("class", "").split()
48
+
49
+ if tag == "tr":
50
+ self.flush()
51
+ return
52
+
53
+ # While capturing a snippet, nested markup (including a nested
54
+ # result-link) stays inside the snippet. Only track nested <td> so the
55
+ # capture still ends at the *outer* </td>.
56
+ if self._capture == "snippet":
57
+ if tag == "td":
58
+ self._depth += 1
59
+ return
60
+
61
+ # Likewise for a title: a nested <a> must not close the capture early.
62
+ if self._capture == "title":
63
+ if tag == "a":
64
+ self._depth += 1
65
+ return
66
+
67
+ if tag == "a" and "result-link" in classes:
68
+ # Row-less fallback: a new result-link implies the previous result
69
+ # is complete even if no <tr> ever bounded it.
70
+ if self._row_href is not None:
71
+ self.flush()
72
+ self._capture = "title"
73
+ self._depth = 1
74
+ self._row_href = attr_dict.get("href", "")
75
+ self._row_title = []
76
+ elif tag == "td" and "result-snippet" in classes:
77
+ self._capture = "snippet"
78
+ self._depth = 1
79
+ self._row_snippet = []
80
+
81
+ def handle_endtag(self, tag: str):
82
+ if self._capture == "title" and tag == "a":
83
+ self._depth -= 1
84
+ if self._depth <= 0:
85
+ self._capture = None
86
+ elif self._capture == "snippet" and tag == "td":
87
+ self._depth -= 1
88
+ if self._depth <= 0:
89
+ self._capture = None
90
+ elif tag == "tr":
91
+ self.flush()
92
+
93
+ def handle_data(self, data: str):
94
+ if self._capture == "title":
95
+ self._row_title.append(data)
96
+ elif self._capture == "snippet":
97
+ self._row_snippet.append(data)
98
+
99
+ def flush(self):
100
+ """Emit the in-progress row, if any. Call after ``feed()`` completes."""
101
+ if self._row_href is not None:
102
+ self.results.append({
103
+ "href": self._row_href,
104
+ "title": "".join(self._row_title).strip(),
105
+ "snippet": "".join(self._row_snippet).strip(),
106
+ })
107
+ self._row_href = None
108
+ self._row_title = []
109
+ self._row_snippet = []
110
+ self._capture = None
111
+ self._depth = 0
112
+
113
+
114
+ def _build_search_query(
17
115
  query: str,
18
116
  allowed_domains: Optional[List[str]] = None,
19
117
  blocked_domains: Optional[List[str]] = None,
20
- max_results: int = 10
21
- ) -> List[Dict[str, str]]:
22
- """Execute search query against DuckDuckGo Lite without API keys."""
118
+ ) -> str:
23
119
  effective_query = query.strip()
24
120
  if allowed_domains:
25
- domain_filters = " " + " OR ".join(f"site:{d}" for d in allowed_domains)
26
- effective_query += domain_filters
121
+ effective_query += " " + " OR ".join(f"site:{d}" for d in allowed_domains)
27
122
  if blocked_domains:
28
- domain_filters = " " + " ".join(f"-site:{d}" for d in blocked_domains)
29
- effective_query += domain_filters
123
+ effective_query += " " + " ".join(f"-site:{d}" for d in blocked_domains)
124
+ return effective_query
30
125
 
126
+
127
+ def _fetch_ddg_html(effective_query: str) -> str:
31
128
  url = "https://lite.duckduckgo.com/lite/"
32
129
  data = urllib.parse.urlencode({"q": effective_query}).encode("utf-8")
33
130
  headers = {
@@ -36,112 +133,143 @@ def search_duckduckgo(
36
133
  "Accept-Language": "en-US,en;q=0.5",
37
134
  "Referer": "https://lite.duckduckgo.com/",
38
135
  }
39
-
40
136
  req = urllib.request.Request(url, data=data, headers=headers)
41
137
  try:
42
138
  with urllib.request.urlopen(req, timeout=15) as resp:
43
- html = resp.read().decode("utf-8", errors="replace")
139
+ return resp.read().decode("utf-8", errors="replace")
44
140
  except Exception as e:
45
141
  sys.stderr.write(f"[epistemic-search] Network error fetching search results: {e}\n")
142
+ return ""
143
+
144
+
145
+ def _parse_clean_url(href: str) -> str:
146
+ m = re.search(r"uddg=([^&]+)", href)
147
+ if m:
148
+ return urllib.parse.unquote(m.group(1))
149
+ return href
150
+
151
+
152
+ def _is_domain_allowed(
153
+ hostname: str,
154
+ allowed_domains: Optional[List[str]] = None,
155
+ blocked_domains: Optional[List[str]] = None,
156
+ ) -> bool:
157
+ if allowed_domains and not any(hostname == d or hostname.endswith("." + d) for d in allowed_domains):
158
+ return False
159
+ if blocked_domains and any(hostname == d or hostname.endswith("." + d) for d in blocked_domains):
160
+ return False
161
+ return True
162
+
163
+
164
+ def search_duckduckgo(
165
+ query: str,
166
+ allowed_domains: Optional[List[str]] = None,
167
+ blocked_domains: Optional[List[str]] = None,
168
+ max_results: int = 10,
169
+ ) -> List[Dict[str, str]]:
170
+ """Execute search query against DuckDuckGo Lite without API keys."""
171
+ effective_query = _build_search_query(query, allowed_domains, blocked_domains)
172
+ html = _fetch_ddg_html(effective_query)
173
+ if not html:
46
174
  return []
47
175
 
48
- # Parse result links and snippets from DuckDuckGo Lite table rows
49
- links = re.findall(
50
- r"<a[^>]*href=['\"]([^'\"]+)['\"][^>]*class=['\"]result-link['\"][^>]*>(.*?)</a>",
51
- html,
52
- re.DOTALL
53
- )
54
- snippets = re.findall(
55
- r"<td[^>]+class=['\"]result-snippet['\"][^>]*>(.*?)</td>",
56
- html,
57
- re.DOTALL
58
- )
176
+ parser = DDGLiteParser()
177
+ parser.feed(html)
178
+ parser.flush()
59
179
 
60
180
  results = []
61
- for (href, title_html), snip_html in zip(links, snippets):
62
- # Unwrap DuckDuckGo redirect if present
63
- m = re.search(r"uddg=([^&]+)", href)
64
- if m:
65
- clean_url = urllib.parse.unquote(m.group(1))
66
- else:
67
- clean_url = href
68
-
181
+ for row in parser.results:
182
+ clean_url = _parse_clean_url(row["href"])
69
183
  if not clean_url.startswith("http"):
70
184
  continue
71
185
 
72
- clean_title = re.sub(r"<[^>]+>", "", title_html).strip()
73
- clean_snippet = re.sub(r"<[^>]+>", "", snip_html).strip()
74
-
75
- # Check domain filtering manually in case DDG syntax didn't catch all
76
186
  parsed_url = urllib.parse.urlparse(clean_url)
77
187
  hostname = parsed_url.hostname or ""
78
- if allowed_domains and not any(hostname == d or hostname.endswith("." + d) for d in allowed_domains):
79
- continue
80
- if blocked_domains and any(hostname == d or hostname.endswith("." + d) for d in blocked_domains):
188
+ if not _is_domain_allowed(hostname, allowed_domains, blocked_domains):
81
189
  continue
82
190
 
83
191
  results.append({
84
- "title": clean_title,
192
+ "title": row["title"],
85
193
  "url": clean_url,
86
- "snippet": clean_snippet
194
+ "snippet": row["snippet"],
87
195
  })
88
-
89
196
  if len(results) >= max_results:
90
197
  break
91
198
 
92
199
  return results
93
200
 
201
+
94
202
  def format_xml(results: List[Dict[str, str]]) -> str:
95
- """Format results as Claude Code <search_results> XML."""
203
+ """Format results as Claude Code <search_results> XML.
204
+
205
+ Every field is XML-escaped at emit time. The parser stores decoded text, so
206
+ without this a snippet like ``A &amp; B`` becomes ``A & B`` and produces
207
+ malformed markup (bare ``&``); URLs containing query strings have the same
208
+ problem.
209
+ """
96
210
  lines = ["<search_results>"]
97
211
  for r in results:
98
212
  lines.append(" <result>")
99
- lines.append(f" <title>{urllib.parse.quote(r['title'])}</title>")
100
- lines.append(f" <url>{r['url']}</url>")
101
- lines.append(f" <snippet>{r['snippet']}</snippet>")
213
+ lines.append(f" <title>{xml_escape(r['title'])}</title>")
214
+ lines.append(f" <url>{xml_escape(r['url'])}</url>")
215
+ lines.append(f" <snippet>{xml_escape(r['snippet'])}</snippet>")
102
216
  lines.append(" </result>")
103
217
  lines.append("</search_results>")
104
218
  return "\n".join(lines)
105
219
 
106
- def main():
107
- query = ""
108
- allowed_domains = None
109
- blocked_domains = None
110
- output_json = False
111
220
 
112
- # Check stdin first
113
- if not sys.stdin.isatty():
221
+ def _read_stdin_payload() -> Tuple[str, Optional[List[str]], Optional[List[str]]]:
222
+ if sys.stdin.isatty():
223
+ return "", None, None
224
+ try:
225
+ data = sys.stdin.read().strip()
226
+ if not data:
227
+ return "", None, None
114
228
  try:
115
- stdin_data = sys.stdin.read().strip()
116
- if stdin_data:
117
- try:
118
- payload = json.loads(stdin_data)
119
- query = payload.get("query", "")
120
- allowed_domains = payload.get("allowed_domains")
121
- blocked_domains = payload.get("blocked_domains")
122
- except json.JSONDecodeError:
123
- query = stdin_data
124
- except Exception:
125
- pass
126
-
127
- # Command line args override
128
- args = sys.argv[1:]
229
+ payload = json.loads(data)
230
+ return (
231
+ payload.get("query", ""),
232
+ payload.get("allowed_domains"),
233
+ payload.get("blocked_domains"),
234
+ )
235
+ except json.JSONDecodeError:
236
+ return data, None, None
237
+ except Exception:
238
+ return "", None, None
239
+
240
+
241
+ def _parse_cli_args(
242
+ args: List[str],
243
+ default_query: str = "",
244
+ default_allowed: Optional[List[str]] = None,
245
+ default_blocked: Optional[List[str]] = None,
246
+ ) -> Tuple[str, Optional[List[str]], Optional[List[str]], bool]:
247
+ query = default_query
248
+ allowed = default_allowed
249
+ blocked = default_blocked
250
+ output_json = False
129
251
  idx = 0
130
252
  while idx < len(args):
131
253
  arg = args[idx]
132
254
  if arg == "--json":
133
255
  output_json = True
134
256
  elif arg == "--allowed-domain" and idx + 1 < len(args):
135
- allowed_domains = allowed_domains or []
136
- allowed_domains.append(args[idx + 1])
257
+ allowed = (allowed or []) + [args[idx + 1]]
137
258
  idx += 1
138
259
  elif arg == "--blocked-domain" and idx + 1 < len(args):
139
- blocked_domains = blocked_domains or []
140
- blocked_domains.append(args[idx + 1])
260
+ blocked = (blocked or []) + [args[idx + 1]]
141
261
  idx += 1
142
262
  elif not query and not arg.startswith("--"):
143
263
  query = arg
144
264
  idx += 1
265
+ return query, allowed, blocked, output_json
266
+
267
+
268
+ def main():
269
+ std_query, std_allowed, std_blocked = _read_stdin_payload()
270
+ query, allowed, blocked, output_json = _parse_cli_args(
271
+ sys.argv[1:], std_query, std_allowed, std_blocked
272
+ )
145
273
 
146
274
  if not query:
147
275
  sys.stderr.write("Usage: search.py [options] <query>\nOr pipe JSON: echo '{\"query\":\"...\"}' | search.py\n")
@@ -149,8 +277,8 @@ def main():
149
277
 
150
278
  results = search_duckduckgo(
151
279
  query=query,
152
- allowed_domains=allowed_domains,
153
- blocked_domains=blocked_domains
280
+ allowed_domains=allowed,
281
+ blocked_domains=blocked,
154
282
  )
155
283
 
156
284
  if output_json:
@@ -158,5 +286,6 @@ def main():
158
286
  else:
159
287
  print(format_xml(results))
160
288
 
289
+
161
290
  if __name__ == "__main__":
162
291
  main()
@@ -277,7 +277,7 @@ def main(argv):
277
277
  handler = {"gate": cmd_gate, "archive": cmd_archive, "stats": cmd_stats}.get(mode)
278
278
  if handler is None:
279
279
  log(f"unknown mode: {mode}")
280
- return 0
280
+ return 1
281
281
  try:
282
282
  out = handler(stdin_data)
283
283
  except Exception as e:
@@ -5,6 +5,7 @@ Implements Matt Pocock-style design tree traversal to isolate the active decisio
5
5
  """
6
6
 
7
7
  import sys
8
+ import os
8
9
  import json
9
10
  import argparse
10
11
  from pathlib import Path
@@ -84,7 +85,8 @@ class DesignTree:
84
85
  return len(self.compute_frontier()) == 0 and all(n.is_settled() for n in self.nodes.values())
85
86
 
86
87
  def export_frontier_json(self, output_path: Path):
87
- output_path.parent.mkdir(parents=True, exist_ok=True)
88
+ safe_path = Path(os.path.realpath(str(output_path)))
89
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
88
90
  data = {
89
91
  "objective": self.objective,
90
92
  "is_complete": self.is_complete(),
@@ -93,12 +95,13 @@ class DesignTree:
93
95
  k: v.settled_answer for k, v in self.nodes.items() if v.is_settled()
94
96
  }
95
97
  }
96
- with open(output_path, "w", encoding="utf-8") as f:
98
+ with open(safe_path, "w", encoding="utf-8") as f:
97
99
  json.dump(data, f, indent=2)
98
100
 
99
101
  @classmethod
100
102
  def load_from_json(cls, file_path: Path) -> 'DesignTree':
101
- with open(file_path, "r", encoding="utf-8") as f:
103
+ safe_path = Path(os.path.realpath(str(file_path)))
104
+ with open(safe_path, "r", encoding="utf-8") as f:
102
105
  data = json.load(f)
103
106
  tree = cls(objective=data["objective"])
104
107
  for k, v in data.get("nodes", {}).items():
@@ -106,15 +109,31 @@ class DesignTree:
106
109
  return tree
107
110
 
108
111
 
112
+ def _display_frontier(tree: DesignTree, frontier: list) -> None:
113
+ print(f"\n🎯 Objective: {tree.objective}")
114
+ print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
115
+ if not frontier:
116
+ if tree.nodes and tree.is_complete():
117
+ print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
118
+ else:
119
+ print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
120
+ else:
121
+ print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
122
+ for idx, node in enumerate(frontier, 1):
123
+ print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
124
+ print(f" {node.question}")
125
+ print(f" ➡️ Recommended: {node.recommended}")
126
+
127
+
109
128
  def main():
110
129
  parser = argparse.ArgumentParser(description="Epistemic Swarm Socratic Decision Tree")
111
130
  parser.add_argument("--objective", type=str, help="Research objective")
112
131
  parser.add_argument("--file", type=str, default=".research/frontier.json", help="Path to frontier.json")
113
132
  parser.add_argument("--show-frontier", action="store_true", help="Print the current decision frontier")
114
133
  parser.add_argument("--settle", nargs=2, metavar=("NODE_ID", "ANSWER"), help="Settle a decision node")
115
-
134
+
116
135
  args = parser.parse_args()
117
- frontier_path = Path(args.file)
136
+ frontier_path = Path(os.path.realpath(str(args.file)))
118
137
 
119
138
  if frontier_path.exists():
120
139
  tree = DesignTree.load_from_json(frontier_path)
@@ -130,19 +149,8 @@ def main():
130
149
 
131
150
  frontier = tree.compute_frontier()
132
151
  if args.show_frontier or not args.settle:
133
- print(f"\n🎯 Objective: {tree.objective}")
134
- print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
135
- if not frontier:
136
- if tree.nodes and tree.is_complete():
137
- print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
138
- else:
139
- print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
140
- else:
141
- print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
142
- for idx, node in enumerate(frontier, 1):
143
- print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
144
- print(f" {node.question}")
145
- print(f" ➡️ Recommended: {node.recommended}")
152
+ _display_frontier(tree, frontier)
153
+
146
154
 
147
155
  if __name__ == "__main__":
148
156
  main()
@@ -14,10 +14,14 @@ from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
  from typing import Dict, Any, Optional, Tuple
16
16
 
17
+ DEFAULT_RESEARCH_DIR = ".research"
18
+ BASE_DIR_HELP = "Base .research directory"
19
+
17
20
  class SourceHasher:
18
21
  def __init__(self, base_dir: Optional[Path] = None):
19
- self.base_dir = base_dir or Path(".research")
20
- self.sources_dir = self.base_dir / "sources"
22
+ target = base_dir or Path(DEFAULT_RESEARCH_DIR)
23
+ self.base_dir = Path(os.path.realpath(str(target)))
24
+ self.sources_dir = Path(os.path.realpath(str(self.base_dir / "sources")))
21
25
  self.sources_dir.mkdir(parents=True, exist_ok=True)
22
26
 
23
27
  @staticmethod
@@ -70,15 +74,28 @@ class SourceHasher:
70
74
  return None
71
75
 
72
76
  def _resolve_hash_file(self, hash_prefix: str, extension: str) -> Optional[Path]:
73
- """Resolves exact or prefix hash to disk path."""
74
- exact_path = self.sources_dir / f"{hash_prefix}{extension}"
77
+ """Resolves exact or prefix hash to disk path.
78
+
79
+ ``hash_prefix`` is untrusted (it arrives from an LLM tool call), so the
80
+ result is required to stay inside ``sources_dir``. Containment is checked
81
+ inline rather than via ``runner.path_safety`` because this module is
82
+ mirrored into the standalone ``research-cache`` plugin, which has no
83
+ ``runner`` package on its path.
84
+ """
85
+ if not re.match(r"^[a-fA-F0-9]+$", hash_prefix):
86
+ return None
87
+ exact_path = Path(os.path.realpath(str(self.sources_dir / f"{hash_prefix}{extension}")))
88
+ if exact_path.parent != self.sources_dir:
89
+ return None
75
90
  if exact_path.exists():
76
91
  return exact_path
77
92
 
78
93
  # If prefix match
79
94
  matches = list(self.sources_dir.glob(f"{hash_prefix}*{extension}"))
80
95
  if len(matches) == 1:
81
- return matches[0]
96
+ candidate = Path(os.path.realpath(str(matches[0])))
97
+ if candidate.parent == self.sources_dir:
98
+ return candidate
82
99
  return None
83
100
 
84
101
  @staticmethod
@@ -143,20 +160,21 @@ def main():
143
160
  cache_parser.add_argument("--title", default="Untitled", help="Document Title")
144
161
  cache_parser.add_argument("--tier", default="WEB_DOCUMENT", help="Source tier")
145
162
  cache_parser.add_argument("--content", help="Raw text content (or read from stdin)")
146
- cache_parser.add_argument("--dir", default=".research", help="Base .research directory")
163
+ cache_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
147
164
 
148
165
  # Verify command
149
166
  verify_parser = subparsers.add_parser("verify", help="Verify a verbatim quote")
150
167
  verify_parser.add_argument("--hash", required=True, help="Document SHA-256 hash")
151
168
  verify_parser.add_argument("--quote", required=True, help="Verbatim quote to check")
152
- verify_parser.add_argument("--dir", default=".research", help="Base .research directory")
169
+ verify_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
153
170
 
154
171
  # List command
155
172
  list_parser = subparsers.add_parser("list", help="List cached sources")
156
- list_parser.add_argument("--dir", default=".research", help="Base .research directory")
173
+ list_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
157
174
 
158
175
  args = parser.parse_args()
159
- hasher = SourceHasher(base_dir=Path(args.dir if hasattr(args, "dir") else ".research"))
176
+ dir_val = getattr(args, "dir", DEFAULT_RESEARCH_DIR) or DEFAULT_RESEARCH_DIR
177
+ hasher = SourceHasher(base_dir=Path(os.path.realpath(str(dir_val))))
160
178
 
161
179
  if args.command == "cache":
162
180
  content = args.content
@@ -10,6 +10,8 @@ import json
10
10
  from pathlib import Path
11
11
  from typing import Dict, Any
12
12
 
13
+ DEFAULT_RESEARCH_DIR = ".research"
14
+
13
15
  DEFAULT_CONFIG: Dict[str, Any] = {
14
16
  "search_engine": "duckduckgo",
15
17
  "max_iterations": 2,
@@ -17,7 +19,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
17
19
  "mode": "research", # "research", "audit", "scout", "hybrid", "brainstorm"
18
20
  "cache_raw_markdown": True,
19
21
  "license_whitelist": ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"],
20
- "output_dir": ".research",
22
+ "output_dir": DEFAULT_RESEARCH_DIR,
21
23
  # Scope allocation policy (Stream F): "dag" = legacy dependency-first
22
24
  # order; "auction" = Frontier Markets (highest expected-information-gain
23
25
  # bid first). Default "dag" preserves existing behavior exactly.
@@ -37,11 +39,11 @@ DEFAULT_CONFIG: Dict[str, Any] = {
37
39
  }
38
40
 
39
41
 
40
- def get_config_path(base_dir: str = ".research") -> Path:
42
+ def get_config_path(base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
41
43
  return Path(base_dir) / "config.json"
42
44
 
43
45
 
44
- def load_config(base_dir: str = ".research") -> Dict[str, Any]:
46
+ def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
45
47
  cfg_file = get_config_path(base_dir)
46
48
  if not cfg_file.exists():
47
49
  return dict(DEFAULT_CONFIG)
@@ -58,7 +60,7 @@ def load_config(base_dir: str = ".research") -> Dict[str, Any]:
58
60
  return dict(DEFAULT_CONFIG)
59
61
 
60
62
 
61
- def save_config(cfg: Dict[str, Any], base_dir: str = ".research") -> Path:
63
+ def save_config(cfg: Dict[str, Any], base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
62
64
  target_dir = Path(base_dir)
63
65
  target_dir.mkdir(parents=True, exist_ok=True)
64
66
  cfg_file = target_dir / "config.json"
@@ -136,27 +138,7 @@ def run_interactive(cfg: Dict[str, Any]) -> Dict[str, Any]:
136
138
  return cfg
137
139
 
138
140
 
139
- def main():
140
- cfg = load_config()
141
- args = sys.argv[1:]
142
-
143
- if not args or "--show" in args:
144
- display_config(cfg)
145
- if not args:
146
- print("To edit settings, run:")
147
- print(" python3 skills/swarm_config/configure.py --interactive")
148
- print(
149
- " python3 skills/swarm_config/configure.py --engine duckduckgo --depth 3 --mode audit\n"
150
- )
151
- return
152
-
153
- if "--interactive" in args or "-i" in args:
154
- cfg = run_interactive(cfg)
155
- cfg_path = save_config(cfg)
156
- print(f"✅ Configuration saved to {cfg_path}")
157
- display_config(cfg)
158
- return
159
-
141
+ def _parse_cli_updates(args: list, cfg: Dict[str, Any]) -> bool:
160
142
  idx = 0
161
143
  modified = False
162
144
  while idx < len(args):
@@ -188,13 +170,35 @@ def main():
188
170
  pass
189
171
  idx += 1
190
172
  idx += 1
173
+ return modified
174
+
191
175
 
176
+ def main():
177
+ cfg = load_config()
178
+ args = sys.argv[1:]
179
+
180
+ if not args or "--show" in args:
181
+ display_config(cfg)
182
+ if not args:
183
+ print("To edit settings, run:")
184
+ print(" python3 skills/swarm_config/configure.py --interactive")
185
+ print(
186
+ " python3 skills/swarm_config/configure.py --engine duckduckgo --depth 3 --mode audit\n"
187
+ )
188
+ return
189
+
190
+ if "--interactive" in args or "-i" in args:
191
+ cfg = run_interactive(cfg)
192
+ cfg_path = save_config(cfg)
193
+ print(f"✅ Configuration saved to {cfg_path}")
194
+ display_config(cfg)
195
+ return
196
+
197
+ modified = _parse_cli_updates(args, cfg)
192
198
  if modified:
193
199
  cfg_path = save_config(cfg)
194
200
  print(f"✅ Configuration updated and saved to {cfg_path}")
195
- display_config(cfg)
196
- else:
197
- display_config(cfg)
201
+ display_config(cfg)
198
202
 
199
203
 
200
204
  if __name__ == "__main__":