research-assistant 0.1.0

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README-ZH.md +115 -0
  3. package/README.md +124 -0
  4. package/npm/bin/research-assistant.js +70 -0
  5. package/npm/scripts/postinstall.js +112 -0
  6. package/package.json +38 -0
  7. package/pyproject.toml +84 -0
  8. package/src/research_assistant/__init__.py +7 -0
  9. package/src/research_assistant/__main__.py +6 -0
  10. package/src/research_assistant/assets/researcher_persona.md +94 -0
  11. package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
  12. package/src/research_assistant/cli.py +155 -0
  13. package/src/research_assistant/commands/__init__.py +7 -0
  14. package/src/research_assistant/commands/ask.py +54 -0
  15. package/src/research_assistant/commands/doctor.py +201 -0
  16. package/src/research_assistant/commands/fetch.py +99 -0
  17. package/src/research_assistant/commands/locate.py +71 -0
  18. package/src/research_assistant/commands/search.py +161 -0
  19. package/src/research_assistant/commands/setup.py +249 -0
  20. package/src/research_assistant/commands/skills.py +60 -0
  21. package/src/research_assistant/config.py +258 -0
  22. package/src/research_assistant/errors.py +106 -0
  23. package/src/research_assistant/fetch/__init__.py +20 -0
  24. package/src/research_assistant/fetch/browser.py +613 -0
  25. package/src/research_assistant/fetch/cfbypass.py +318 -0
  26. package/src/research_assistant/fetch/html2md.py +44 -0
  27. package/src/research_assistant/fetch/normal.py +69 -0
  28. package/src/research_assistant/fetch/search_engine.py +324 -0
  29. package/src/research_assistant/fetch/snapshot.py +50 -0
  30. package/src/research_assistant/http.py +118 -0
  31. package/src/research_assistant/installer.py +270 -0
  32. package/src/research_assistant/locate/__init__.py +5 -0
  33. package/src/research_assistant/locate/engine.py +289 -0
  34. package/src/research_assistant/loginstate/__init__.py +10 -0
  35. package/src/research_assistant/loginstate/daemon.py +145 -0
  36. package/src/research_assistant/loginstate/daemon_cli.py +21 -0
  37. package/src/research_assistant/loginstate/daemonctl.py +116 -0
  38. package/src/research_assistant/loginstate/extension/background.js +167 -0
  39. package/src/research_assistant/loginstate/extension/manifest.json +15 -0
  40. package/src/research_assistant/loginstate/extension/popup.html +31 -0
  41. package/src/research_assistant/loginstate/extension/popup.js +33 -0
  42. package/src/research_assistant/output.py +140 -0
  43. package/src/research_assistant/providers/__init__.py +10 -0
  44. package/src/research_assistant/providers/base.py +93 -0
  45. package/src/research_assistant/providers/browser.py +91 -0
  46. package/src/research_assistant/providers/context7.py +171 -0
  47. package/src/research_assistant/providers/exa.py +353 -0
  48. package/src/research_assistant/providers/firecrawl.py +644 -0
  49. package/src/research_assistant/providers/openai_compat.py +293 -0
  50. package/src/research_assistant/providers/registry.py +62 -0
  51. package/src/research_assistant/providers/tavily.py +346 -0
  52. package/src/research_assistant/proxy.py +58 -0
  53. package/src/research_assistant/targets.py +66 -0
@@ -0,0 +1,270 @@
1
+ """managed 文件安装器(因家异构,ADR-0008 / D7)。
2
+
3
+ - SKILL.md(bundled asset)相对通用,装各家 skill 目录。
4
+ - researcher agent 定义因家而异:Claude/Cursor=md+frontmatter、Codex=toml、Hermes=无(融入 skill)。
5
+ - 附带复制 MV3 扩展到 ~/.research-assistant/extension/(供用户在浏览器加载一次)。
6
+
7
+ 幂等:重复 install 覆盖;status 对比 installed 与 bundled 内容判定 missing/stale/up-to-date/extra。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from importlib import resources
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .errors import ResearchAssistantError
18
+ from .targets import AGENT_NAME, ALL_FAMILIES, SKILL_NAME, Family
19
+
20
+ _ASSET_ROOT = ("assets",)
21
+ _PERSONA_RESOURCE = ("assets", "researcher_persona.md")
22
+ _SKILL_RESOURCE = ("assets", "skills", SKILL_NAME, "SKILL.md")
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # asset 读取
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def _read_text(resource_path: tuple[str, ...]) -> str:
31
+ """优先 importlib.resources(打包后),失败回退文件系统(dev 从 src 运行)。"""
32
+ try:
33
+ root = resources.files("research_assistant")
34
+ for part in resource_path:
35
+ root = root.joinpath(part)
36
+ return root.read_text(encoding="utf-8")
37
+ except Exception:
38
+ fs_root = Path(__file__).resolve().parents[1]
39
+ p = fs_root.joinpath(*resource_path)
40
+ return p.read_text(encoding="utf-8")
41
+
42
+
43
+ def persona_text() -> str:
44
+ return _read_text(_PERSONA_RESOURCE)
45
+
46
+
47
+ def skill_text() -> str:
48
+ return _read_text(_SKILL_RESOURCE)
49
+
50
+
51
+ def extension_source_dir() -> Path:
52
+ """MV3 扩展源目录(bundled)。优先 importlib.resources,回退文件系统。"""
53
+ try:
54
+ from importlib import resources
55
+
56
+ root = resources.files("research_assistant").joinpath("loginstate", "extension")
57
+ if root.is_dir():
58
+ with resources.as_file(root) as p:
59
+ return Path(p)
60
+ except Exception:
61
+ pass
62
+ fs_root = Path(__file__).resolve().parent # src/research_assistant/
63
+ p = fs_root / "loginstate" / "extension"
64
+ if p.is_dir():
65
+ return p
66
+ raise ResearchAssistantError("MV3 扩展源未找到(loginstate/extension 缺失)")
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # 因家生成 agent 定义
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def generate_agent(family: Family) -> str | None:
75
+ """按家生成 researcher agent 定义内容;Hermes 返回 None(无独立文件)。"""
76
+ persona = persona_text()
77
+ if family.agent_format == "none":
78
+ return None
79
+ if family.agent_format == "toml":
80
+ return _generate_codex_toml(persona)
81
+ return _generate_agent_md(family, persona)
82
+
83
+
84
+ def _frontmatter_line(value: Any) -> str:
85
+ if isinstance(value, str) and ("\n" in value or ":" in value):
86
+ # 多行/含冒号的值用引号包裹并转义内部引号(不用 f-string 内反斜杠,兼容 3.10)
87
+ escaped = value.replace('"', '\\"')
88
+ return '"' + escaped + '"'
89
+ return str(value)
90
+
91
+
92
+ def _generate_agent_md(family: Family, persona: str) -> str:
93
+ fields: dict[str, Any] = {
94
+ "name": AGENT_NAME,
95
+ "description": (
96
+ "Use this sub-agent for research that needs external information (web search, docs, "
97
+ "page fetching). It runs in isolation and returns only a summary; the full report is on disk."
98
+ ),
99
+ }
100
+ fields.update(family.md_fields)
101
+ fm_lines = ["---"]
102
+ for k, v in fields.items():
103
+ fm_lines.append(f"{k}: {_frontmatter_line(v)}")
104
+ fm_lines.append("---")
105
+ fm_lines.append("")
106
+ return "\n".join(fm_lines) + persona
107
+
108
+
109
+ def _generate_codex_toml(persona: str) -> str:
110
+ # Codex AgentRoleToml schema(openai/codex 源码确认,deepwiki 2026-07):
111
+ # 顶层合法字段:name / description / developer_instructions / model / nickname_candidates / config_file。
112
+ # sandbox 经子表配置([sandbox_workspace_write]),mcp_servers 属 config.toml 顶层 [mcp_servers] 表——
113
+ # 二者均非 agent 文件顶层字段。此前误写的 sandbox_mode / mcp_servers 顶层键已移除,避免 codex 解析告警。
114
+ # model 省略 → 继承 codex 全局 model 配置(不写非法的 "inherit" 字面值)。
115
+ # developer_instructions 用三引号 toml 字符串承载 persona。
116
+ return (
117
+ f'name = "{AGENT_NAME}"\n'
118
+ f'description = "Research sub-agent: web search, docs, page fetch. Runs in isolation, returns summary only."\n'
119
+ f'developer_instructions = """\n{persona.strip()}\n"""\n'
120
+ )
121
+
122
+
123
+ def skill_for_family(family: Family) -> str:
124
+ """SKILL.md 内容。Hermes 把 researcher 人设融入(ADR-0008 D7)。"""
125
+ base = skill_text()
126
+ if family.name == "hermes":
127
+ # Hermes 无独立 agent 文件,把 persona 附到 skill 末尾
128
+ return base.rstrip() + "\n\n## researcher persona (runtime delegation)\n\n" + persona_text().strip() + "\n"
129
+ return base
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # 路径解析
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def _home(root: str | Path | None) -> Path:
138
+ if root:
139
+ return Path(root).expanduser()
140
+ return Path.home()
141
+
142
+
143
+ def skill_path(family: Family, root: str | Path | None = None) -> Path:
144
+ return _home(root) / family.skill_relative / "SKILL.md"
145
+
146
+
147
+ def agent_path(family: Family, root: str | Path | None = None) -> Path | None:
148
+ if family.agent_relative is None:
149
+ return None
150
+ return _home(root) / family.agent_relative
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # install / status / update
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ def install(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
159
+ home = _home(root)
160
+ installed: list[dict[str, Any]] = []
161
+ failed: list[dict[str, str]] = []
162
+ for fname in families:
163
+ family = ALL_FAMILIES[fname]
164
+ try:
165
+ sp = skill_path(family, home)
166
+ sp.parent.mkdir(parents=True, exist_ok=True)
167
+ sp.write_text(skill_for_family(family), encoding="utf-8")
168
+ entry: dict[str, Any] = {
169
+ "family": fname,
170
+ "skill_path": str(sp),
171
+ }
172
+ ap = agent_path(family, home)
173
+ if ap is not None:
174
+ ap.parent.mkdir(parents=True, exist_ok=True)
175
+ ap.write_text(generate_agent(family) or "", encoding="utf-8")
176
+ entry["agent_path"] = str(ap)
177
+ entry["agent_format"] = family.agent_format
178
+ else:
179
+ entry["agent_path"] = None
180
+ entry["agent_format"] = "none"
181
+ installed.append(entry)
182
+ except OSError as e:
183
+ failed.append({"family": fname, "error": str(e)})
184
+
185
+ # 附带复制 MV3 扩展(登录态层,ADR-0006)
186
+ extension_copied = False
187
+ extension_dest = None
188
+ try:
189
+ extension_dest = _copy_extension()
190
+ extension_copied = True
191
+ except Exception as e:
192
+ failed.append({"family": "extension", "error": str(e)})
193
+
194
+ return {
195
+ "root": str(home),
196
+ "installed": installed,
197
+ "failed": failed,
198
+ "extension_copied": extension_copied,
199
+ "extension_path": str(extension_dest) if extension_dest else None,
200
+ }
201
+
202
+
203
+ def status(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
204
+ home = _home(root)
205
+ out: list[dict[str, Any]] = []
206
+ for fname in families:
207
+ family = ALL_FAMILIES[fname]
208
+ item = _status_family(family, home)
209
+ item["family"] = fname
210
+ out.append(item)
211
+ counts: dict[str, int] = {}
212
+ for it in out:
213
+ s = it["skill_status"]
214
+ counts[s] = counts.get(s, 0) + 1
215
+ return {"root": str(home), "targets": out, "status_counts": counts}
216
+
217
+
218
+ def update(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
219
+ return install(families, root=root)
220
+
221
+
222
+ def _status_family(family: Family, home: Path) -> dict[str, Any]:
223
+ sp = skill_path(family, home)
224
+ bundled_skill = skill_for_family(family)
225
+ skill_status = _compare(sp, bundled_skill)
226
+ result: dict[str, Any] = {
227
+ "skill_path": str(sp),
228
+ "skill_status": skill_status,
229
+ }
230
+ ap = agent_path(family, home)
231
+ if ap is not None:
232
+ bundled_agent = generate_agent(family) or ""
233
+ result["agent_path"] = str(ap)
234
+ result["agent_status"] = _compare(ap, bundled_agent)
235
+ else:
236
+ result["agent_path"] = None
237
+ result["agent_status"] = "none"
238
+ return result
239
+
240
+
241
+ def _compare(path: Path, bundled: str) -> str:
242
+ """对比 installed 与 bundled:missing/stale/up-to-date/extra。"""
243
+ if not path.exists():
244
+ return "missing"
245
+ try:
246
+ installed = path.read_text(encoding="utf-8")
247
+ except OSError:
248
+ return "missing"
249
+ if installed == bundled:
250
+ return "up-to-date"
251
+ return "stale"
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # 扩展复制
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ def _copy_extension() -> Path:
260
+ """把 bundled MV3 扩展复制到 ~/.research-assistant/extension/。"""
261
+ import shutil
262
+
263
+ from . import config as config_mod
264
+
265
+ src = extension_source_dir()
266
+ dest = config_mod.config_dir() / "extension"
267
+ if dest.exists():
268
+ shutil.rmtree(dest)
269
+ shutil.copytree(src, dest)
270
+ return dest
@@ -0,0 +1,5 @@
1
+ """locate 包:锚点法速览预筛。"""
2
+
3
+ from .engine import Anchor, locate
4
+
5
+ __all__ = ["Anchor", "locate"]
@@ -0,0 +1,289 @@
1
+ """locate 包:锚点法速览预筛(PM §4.3 / ADR locate 非强制)。
2
+
3
+ 锚点法:模型输出原文里实际存在的一句话(quote)+ 相关度;行号由代码字符串匹配算出
4
+ (模型擅长找关键句、代码擅长计数)。行号一律基于已落盘 md 副本(稳定性,基线 §7.4)。
5
+
6
+ 流程:
7
+ 读 md → 切块(paragraph / lines 窗口)→ 高并发小模型批量打分(输出 quote+relevance XML)
8
+ → 代码匹配 quote 到行号区间 → top N + 周边 K 行 context
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import re
15
+ import xml.etree.ElementTree as ET
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from ..config import Config
20
+ from ..providers import openai_compat
21
+
22
+
23
+ @dataclass
24
+ class Chunk:
25
+ text: str
26
+ line_start: int # 1-based
27
+ line_end: int # 1-based
28
+
29
+
30
+ @dataclass
31
+ class Anchor:
32
+ quote: str
33
+ line_start: int
34
+ line_end: int
35
+ scope: str
36
+ context: str
37
+ relevance: float
38
+
39
+
40
+ def chunk_md(text: str, scope: str, window: int = 40) -> list[Chunk]:
41
+ """把 md 文本切成块。scope=paragraph 按空行分段;scope=lines 按固定行窗口。"""
42
+ lines = text.splitlines()
43
+ if scope == "lines":
44
+ chunks: list[Chunk] = []
45
+ i = 0
46
+ while i < len(lines):
47
+ block = lines[i : i + window]
48
+ if any(s.strip() for s in block):
49
+ chunks.append(Chunk("\n".join(block), i + 1, i + len(block)))
50
+ i += window
51
+ return chunks
52
+ # paragraph:按空行分段,记录行号
53
+ chunks: list[Chunk] = []
54
+ start = 0
55
+ buf: list[str] = []
56
+ for idx, line in enumerate(lines):
57
+ if line.strip() == "":
58
+ if any(s.strip() for s in buf):
59
+ chunks.append(Chunk("\n".join(buf), start + 1, start + len(buf)))
60
+ buf = []
61
+ start = idx + 1
62
+ else:
63
+ if not buf:
64
+ start = idx
65
+ buf.append(line)
66
+ if any(s.strip() for s in buf):
67
+ chunks.append(Chunk("\n".join(buf), start + 1, start + len(buf)))
68
+ return chunks
69
+
70
+
71
+ _PROMPT_SYSTEM = (
72
+ "You locate the most relevant passage in a text chunk for a given question. "
73
+ "If (and only if) the chunk contains content relevant to the question, output an XML <anchor> with:\n"
74
+ " <quote>: an EXACT, verbatim sentence or phrase COPIED from the chunk (do not paraphrase).\n"
75
+ " <relevance>: a float 0.0-1.0.\n"
76
+ "If the chunk is irrelevant, output <anchors></anchors> with nothing inside. "
77
+ "Output ONLY the XML, no prose."
78
+ )
79
+
80
+
81
+ def _build_user(query: str, chunk_text: str) -> str:
82
+ return (
83
+ f"Question: {query}\n\n"
84
+ f"Chunk:\n\"\"\"\n{chunk_text}\n\"\"\"\n\n"
85
+ f"Output the <anchors> XML now."
86
+ )
87
+
88
+
89
+ _ANCHOR_RE = re.compile(r"<anchor>(.*?)</anchor>", re.DOTALL)
90
+ _QUOTE_RE = re.compile(r"<quote>(.*?)</quote>", re.DOTALL)
91
+ _REL_RE = re.compile(r"<relevance>(.*?)</relevance>", re.DOTALL)
92
+
93
+
94
+ def parse_anchors_xml(content: str) -> list[tuple[str, float]]:
95
+ """从模型输出解析 (quote, relevance) 列表(容忍外层包裹的非 XML 文本)。"""
96
+ results: list[tuple[str, float]] = []
97
+ for m in _ANCHOR_RE.finditer(content):
98
+ block = m.group(1)
99
+ q = _QUOTE_RE.search(block)
100
+ r = _REL_RE.search(block)
101
+ if not q:
102
+ continue
103
+ quote = _decode_xml_text(q.group(1)).strip()
104
+ if not quote:
105
+ continue
106
+ rel = 0.5
107
+ if r:
108
+ try:
109
+ rel = float(_decode_xml_text(r.group(1)).strip())
110
+ except ValueError:
111
+ rel = 0.5
112
+ results.append((quote, max(0.0, min(1.0, rel))))
113
+ # 兜底:用 ElementTree 再试一次(模型偶尔回合法 XML)
114
+ if not results:
115
+ try:
116
+ root = ET.fromstring(content.strip())
117
+ for a in root.iter("anchor"):
118
+ q = a.findtext("quote")
119
+ r = a.findtext("relevance")
120
+ if q and q.strip():
121
+ try:
122
+ rel = float(r) if r else 0.5
123
+ except ValueError:
124
+ rel = 0.5
125
+ results.append((q.strip(), max(0.0, min(1.0, rel))))
126
+ except ET.ParseError:
127
+ pass
128
+ return results
129
+
130
+
131
+ def _decode_xml_text(s: str) -> str:
132
+ return (
133
+ s.replace("&lt;", "<")
134
+ .replace("&gt;", ">")
135
+ .replace("&amp;", "&")
136
+ .replace("&quot;", '"')
137
+ .replace("&apos;", "'")
138
+ )
139
+
140
+
141
+ def _normalize_ws(s: str) -> str:
142
+ return " ".join(s.split())
143
+
144
+
145
+ def find_quote_lines(full_text: str, quote: str) -> tuple[int, int] | None:
146
+ """在全文中定位 quote 的行号区间(1-based)。归一化空白后做子串匹配。
147
+
148
+ 模型可能微调标点/空白,故采用归一化匹配;找不到返回 None。
149
+ """
150
+ norm_quote = _normalize_ws(quote)
151
+ if not norm_quote:
152
+ return None
153
+ # 取 quote 的特征前缀/后缀(避免模型夹带多余字)
154
+ lines = full_text.splitlines()
155
+ norm_lines = [_normalize_ws(l) for l in lines]
156
+ joined = "\n".join(norm_lines)
157
+
158
+ # 直接整体匹配
159
+ pos = _fuzzy_find(joined, norm_quote)
160
+ if pos is None:
161
+ # 退化为首句匹配(取 quote 第一个句子)
162
+ first_sentence = re.split(r"(?<=[。.!??])\s", norm_quote)
163
+ probe = first_sentence[0] if first_sentence else norm_quote[:60]
164
+ if len(probe) < 8:
165
+ return None
166
+ pos = _fuzzy_find(joined, probe)
167
+ if pos is None:
168
+ return None
169
+ # 把字符偏移映射回行号(基于 joined 的换行结构)
170
+ return _offset_to_lines(joined, pos, norm_quote)
171
+
172
+
173
+ def _fuzzy_find(haystack: str, needle: str) -> int | None:
174
+ """在归一化文本里找 needle;先精确,失败则用最长 token 序列近似。"""
175
+ idx = haystack.find(needle)
176
+ if idx >= 0:
177
+ return idx
178
+ # 容忍 needle 中间多了少量字符:取首尾各 ~20 字符做定位
179
+ if len(needle) > 40:
180
+ head, tail = needle[:20], needle[-20:]
181
+ hi = haystack.find(head)
182
+ if hi >= 0:
183
+ tj = haystack.find(tail, hi)
184
+ if tj >= 0:
185
+ return hi
186
+ return None
187
+
188
+
189
+ def _offset_to_lines(joined: str, pos: int, needle: str) -> tuple[int, int]:
190
+ """joined 是 '\\n'.join(norm_lines);把 [pos, pos+len) 映射到 1-based 行号。"""
191
+ # 计算每行起始偏移
192
+ line_starts = [0]
193
+ for i, ch in enumerate(joined):
194
+ if ch == "\n":
195
+ line_starts.append(i + 1)
196
+ end = min(pos + len(needle), len(joined))
197
+ start_line = _bisect_line(line_starts, pos)
198
+ end_line = _bisect_line(line_starts, max(pos, end - 1))
199
+ return start_line + 1, end_line + 1
200
+
201
+
202
+ def _bisect_line(line_starts: list[int], offset: int) -> int:
203
+ # 最大 i 使 line_starts[i] <= offset
204
+ import bisect
205
+
206
+ return bisect.bisect_right(line_starts, offset) - 1
207
+
208
+
209
+ def build_context(lines: list[str], line_start: int, line_end: int, k: int) -> str:
210
+ """取 [line_start, line_end] 周围各 K 行的上下文文本。"""
211
+ lo = max(1, line_start - k)
212
+ hi = min(len(lines), line_end + k)
213
+ return "\n".join(lines[lo - 1 : hi])
214
+
215
+
216
+ async def locate(
217
+ config: Config,
218
+ md_text: str,
219
+ query: str,
220
+ *,
221
+ scope: str = "paragraph",
222
+ top: int = 5,
223
+ context_k: int = 3,
224
+ concurrency: int | None = None,
225
+ ) -> list[Anchor]:
226
+ """主入口:对 md_text 跑锚点定位,返回 top N anchors(按 relevance 降序)。"""
227
+ if concurrency is None:
228
+ loc_cfg = config.provider("locate")
229
+ concurrency = loc_cfg.concurrency if loc_cfg else 8
230
+
231
+ chunks = chunk_md(md_text, scope)
232
+ if not chunks:
233
+ return []
234
+
235
+ sem = asyncio.Semaphore(max(1, concurrency))
236
+
237
+ async def score_one(chunk: Chunk) -> list[tuple[str, float, Chunk]]:
238
+ async with sem:
239
+ resp = await openai_compat.chat_completion(
240
+ config,
241
+ [
242
+ {"role": "system", "content": _PROMPT_SYSTEM},
243
+ {"role": "user", "content": _build_user(query, chunk.text)},
244
+ ],
245
+ provider_type="locate",
246
+ temperature=0.0,
247
+ )
248
+ msg = openai_compat.extract_message(resp)
249
+ content = openai_compat.strip_think(msg.get("content") or "")
250
+ if isinstance(content, list):
251
+ content = "\n".join(seg.get("text", "") for seg in content if isinstance(seg, dict))
252
+ parsed = parse_anchors_xml(content)
253
+ return [(q, r, chunk) for q, r in parsed]
254
+
255
+ batch_results = await asyncio.gather(*[score_one(c) for c in chunks], return_exceptions=True)
256
+
257
+ lines = md_text.splitlines()
258
+ anchors: list[Anchor] = []
259
+ for res in batch_results:
260
+ if isinstance(res, Exception):
261
+ continue
262
+ for quote, rel, chunk in res:
263
+ span = find_quote_lines(md_text, quote)
264
+ if span is None:
265
+ # 退回 chunk 自身范围(行号稳定性优先用全文匹配,失败才回退)
266
+ ls, le = chunk.line_start, chunk.line_end
267
+ else:
268
+ ls, le = span
269
+ anchors.append(
270
+ Anchor(
271
+ quote=quote,
272
+ line_start=ls,
273
+ line_end=le,
274
+ scope=scope,
275
+ context=build_context(lines, ls, le, context_k),
276
+ relevance=rel,
277
+ )
278
+ )
279
+
280
+ # 去重(相同行区间)+ 排序 + top N
281
+ seen: set[tuple[int, int]] = set()
282
+ unique: list[Anchor] = []
283
+ for a in sorted(anchors, key=lambda x: x.relevance, reverse=True):
284
+ key = (a.line_start, a.line_end)
285
+ if key in seen:
286
+ continue
287
+ seen.add(key)
288
+ unique.append(a)
289
+ return unique[:top]
@@ -0,0 +1,10 @@
1
+ """登录态层(ADR-0006):内嵌 cdt 扩展桥。
2
+
3
+ - MV3 扩展(bundled,装在用户日常浏览器)用 chrome.cookies 读明文 cookie(绕 ABE)→ 推 daemon。
4
+ - cookie daemon(Python 移植自 cdt)缓存并暴露 /cookies。
5
+ - fetch 启动时取 cookie,add_cookies 注入隔离 profile(避 lock)。
6
+ """
7
+
8
+ from .daemonctl import daemon_status, ensure_daemon, get_cookies, to_playwright_cookies
9
+
10
+ __all__ = ["daemon_status", "ensure_daemon", "get_cookies", "to_playwright_cookies"]