@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.js +59 -7
- package/config/mcp_launcher.py +75 -48
- package/extensions/pi/index.js +25 -5
- package/install.sh +5 -5
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/gemini/gemini-extension.json +1 -1
- package/plugins/opencode/index.js +308 -142
- package/plugins/opencode/tui.js +7 -9
- package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/auditor_engine.py +122 -102
- package/runner/claim_store.py +5 -4
- package/runner/living_dossiers.py +32 -10
- package/runner/mcp_protocol.py +1 -1
- package/runner/mcp_server.py +23 -11
- package/runner/path_safety.py +62 -0
- package/runner/pcrb.py +68 -39
- package/runner/pcrb_verify.py +82 -52
- package/runner/refinement.py +61 -47
- package/runner/research_swarm.py +61 -7
- package/runner/state_machine.py +9 -6
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_mcp_server.py +0 -1
- package/runner/tests/test_opencode_ux.py +368 -33
- package/runner/tests/test_swarm.py +20 -9
- package/runner/tests/test_sweep_regressions.py +373 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/auction_experiment.py +38 -30
- package/scripts/bet1_advisory_spike.py +41 -25
- package/scripts/build_adapters.py +52 -37
- package/scripts/divergence_experiment.py +61 -31
- package/skills/brainstorming/scripts/brainstorm.py +3 -2
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -1
- package/skills/epistemic_search/scripts/search.py +202 -73
- package/skills/epistemic_search/scripts/webcache.py +1 -1
- package/skills/grilling/socratic_tree.py +26 -18
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +27 -9
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- 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
|
|
9
|
+
import sys
|
|
11
10
|
import urllib.parse
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
effective_query += domain_filters
|
|
121
|
+
effective_query += " " + " OR ".join(f"site:{d}" for d in allowed_domains)
|
|
27
122
|
if blocked_domains:
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
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":
|
|
192
|
+
"title": row["title"],
|
|
85
193
|
"url": clean_url,
|
|
86
|
-
"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 & 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>{
|
|
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
|
-
|
|
113
|
-
if
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
args
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
153
|
-
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()
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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()
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
20
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
173
|
+
list_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
|
|
157
174
|
|
|
158
175
|
args = parser.parse_args()
|
|
159
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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":
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
196
|
-
else:
|
|
197
|
-
display_config(cfg)
|
|
201
|
+
display_config(cfg)
|
|
198
202
|
|
|
199
203
|
|
|
200
204
|
if __name__ == "__main__":
|