qi-coding-agent 0.1.0__py3-none-any.whl

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 (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/registry.py ADDED
@@ -0,0 +1,485 @@
1
+ """注册表:ToolCatalog / 扩展能力注册表 + 扩展发现(docs/extensions.md)。
2
+
3
+ `AgentRegistry` 不在 core 里:P-E4c 把「角色」整块移出(归 qi-agents),这里不再有它。
4
+ `Tool` / `ToolError` / `ToolExecutor` 在 P-E2a 搬到了 `extensions.py`(扩展宿主的公开面);
5
+ 这里只**转发导入**,旧写法 `from qi_agent.registry import Tool` 仍然有效。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import inspect
12
+ import re
13
+ import sys
14
+ from collections.abc import Callable, Iterable
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import paths
20
+ from .extensions import (
21
+ CliCommandRegistry,
22
+ CommandRegistry,
23
+ ExtensionApi,
24
+ ExtensionBus,
25
+ FlagRegistry,
26
+ Tool,
27
+ ToolError,
28
+ ToolExecutor,
29
+ )
30
+ __all__ = [
31
+ "CapabilityRegistry",
32
+ "CommandRegistry",
33
+ "EXTENSION_ENTRY_FILE",
34
+ "EXTENSION_ENTRY_POINT_GROUP",
35
+ "Tool",
36
+ "ToolCatalog",
37
+ "ToolError",
38
+ "ToolExecutor",
39
+ "discover_extensions",
40
+ ]
41
+
42
+ # 扩展本地目录通道:目录含 extension.py,export register(api)
43
+ EXTENSION_ENTRY_FILE = "extension.py"
44
+ #: pip 通道的 entry point 组(v1 叫 `qi.plugins`;**cold cut,不留别名** —— 见 design/extensions-design.md 的 E1)
45
+ EXTENSION_ENTRY_POINT_GROUP = "qi.extensions"
46
+ #: 宿主自己的 distribution 名(禁写进扩展的 dependencies —— 见 docs/extensions.md 的依赖契约)
47
+ HOST_DISTRIBUTION = "qi-coding-agent"
48
+ #: 旧名。PyPI 上的 `qi-agent` **是别人的项目**,所以扩展依赖里出现它不只是"多写了一条":
49
+ #: 它会真的装一个不相干的包(还可能和 qi 的 import 包在 site-packages 里撞车),单独报一条。
50
+ #: (import 包名仍是 `qi_agent`、命令仍是 `qi` —— 只有发行名被占用了。)
51
+ LEGACY_HOST_DISTRIBUTION = "qi-agent"
52
+
53
+ # `packaging` 是可选依赖:它在绝大多数环境里经由 setuptools/pip/pytest 存在,
54
+ # 但 qi 自己不依赖它 —— 拿不到就只报告原始 spec,不猜“满不满足”。
55
+ try: # pragma: no cover - 取决于环境
56
+ from packaging.requirements import Requirement as _Requirement
57
+ from packaging.version import Version as _Version
58
+ except Exception: # noqa: BLE001
59
+ _Requirement = None # type: ignore[assignment]
60
+ _Version = None # type: ignore[assignment]
61
+
62
+ _LEADING_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
63
+
64
+
65
+ def _normalize_dist_name(name: str) -> str:
66
+ """PEP 503 归一:大小写与 `-_.` 都视为等价。"""
67
+ return re.sub(r"[-_.]+", "-", name or "").strip().lower()
68
+
69
+
70
+ def _requirement_targets(req: str, distribution: str) -> bool:
71
+ """这条依赖是否指向给定的 distribution(不借助 packaging 也能判定 —— 它只影响诊断)。"""
72
+ name = ""
73
+ if _Requirement is not None:
74
+ try:
75
+ name = _Requirement(req).name
76
+ except Exception: # noqa: BLE001 坏 spec 不拖垮装载
77
+ name = ""
78
+ if not name:
79
+ match = _LEADING_NAME.match(req)
80
+ name = match.group(1) if match else ""
81
+ return _normalize_dist_name(name) == _normalize_dist_name(distribution)
82
+
83
+
84
+ def installed_host_version() -> str | None:
85
+ """当前装着的 qi 宿主版本(拿不到就 None)。"""
86
+ import importlib.metadata as metadata
87
+
88
+ try:
89
+ return metadata.version(HOST_DISTRIBUTION)
90
+ except Exception: # noqa: BLE001 包元数据缺失(源码直跑等)
91
+ return None
92
+
93
+
94
+ def _specifier_allows(reqs: list[str], version: str | None) -> bool | None:
95
+ """这些依赖约束是否被当前版本满足;None = 判定不了(没 packaging / 没版本号)。
96
+
97
+ 只看两个名字是不是 None 就够 —— 不需要另一个 `_HAS_PACKAGING` 标志把同一件事
98
+ 说两遍(那种重复校验最后总会有一次忘了同步)。
99
+ """
100
+ if _Requirement is None or _Version is None or version is None:
101
+ return None
102
+ try:
103
+ parsed = _Version(version)
104
+ return all(parsed in _Requirement(r).specifier for r in reqs)
105
+ except Exception: # noqa: BLE001 版本号非法等
106
+ return None
107
+
108
+
109
+ def warn_host_dependency(name: str, dist: Any,
110
+ report: Callable[[str], None] | None, *,
111
+ script: Any = None) -> None:
112
+ """报告“扩展把宿主写进了依赖”这件亊(§5.5 / E11)。
113
+
114
+ 为什么不静默:pi 用 `peerDependencies` + `"*"` 表达“宿主提供、别自己打包”,
115
+ 而 Python **没有** peer 这个概念 —— 扩展一旦写 `qi-coding-agent==0.1.0`,pip 就会在解析时
116
+ 把我们自己的 qi 降级(宿主被自己的扩展踢掉)。这类故障现场和根因隔得很远,
117
+ 所以到了装完才报就晚了;装载时就指出。
118
+
119
+ **只报告不拒绝**:声明它本身不危险(危险的是被 pip 解成一棵冲突的树),
120
+ 拒载会让一个本来能跑的扩展直接不可用 —— 而用户此刻需要的是“知道并去改 pyproject”。
121
+ """
122
+ if report is None:
123
+ return
124
+ if dist is not None: # pip 通道:读装上的 dist 元数据
125
+ requires = [str(r) for r in (getattr(dist, "requires", None) or ())]
126
+ where = "pyproject 的 dependencies"
127
+ elif script is not None: # 目录通道:读 PEP 723 的内联声明
128
+ from .pep723 import dependencies_of
129
+
130
+ requires = dependencies_of(script)
131
+ where = "脚本的 `# /// script` 声明"
132
+ else:
133
+ return
134
+ hint = ("从 pyproject 里去掉" if dist is not None
135
+ else "从脚本的 `# /// script` 声明里去掉")
136
+ legacy_specs = [r for r in requires if _requirement_targets(r, LEGACY_HOST_DISTRIBUTION)]
137
+ if legacy_specs:
138
+ report(f"扩展 {name} 的依赖里有 {LEGACY_HOST_DISTRIBUTION}({where}):"
139
+ f"{'、'.join(legacy_specs)}。那个名字在 PyPI 上**属于别的项目**,不是 qi 宿主"
140
+ f"(宿主的发行名现在是 {HOST_DISTRIBUTION};import 包名仍是 `qi_agent`)—— 请{hint},"
141
+ f"否则 pip 会装一个不相干的包,或在同一个环境里和 qi 撞 site-packages。")
142
+ host_specs = [r for r in requires if _requirement_targets(r, HOST_DISTRIBUTION)]
143
+ if not host_specs:
144
+ return
145
+ installed = installed_host_version()
146
+ allowed = _specifier_allows(host_specs, installed)
147
+ verdict = ("" if allowed is None else
148
+ "当前版本**满足**它" if allowed else "当前版本**不满足**它")
149
+ detail = "" if allowed is not None else "(未能判定是否满足:缺 packaging 或版本信息)"
150
+ report(f"扩展 {name} 把宿主 {HOST_DISTRIBUTION} 写进了依赖({where}):"
151
+ f"{'、'.join(host_specs)};当前 {HOST_DISTRIBUTION} {installed or '未知'}。"
152
+ f"{verdict}{detail} "
153
+ f"宿主应当由 qi 自己提供,不该出现在扩展的依赖里(否则 pip 可能把 qi 自己降级)"
154
+ f"—— 请{hint}。")
155
+
156
+
157
+ class ToolCatalog:
158
+ def __init__(self) -> None:
159
+ self._tools: dict[str, Tool] = {}
160
+
161
+ def register(self, tool: Tool) -> None:
162
+ if tool.name in self._tools:
163
+ raise ValueError(f"工具重复注册: {tool.name}")
164
+ self._tools[tool.name] = tool
165
+
166
+ def get(self, name: str) -> Tool | None:
167
+ return self._tools.get(name)
168
+
169
+ @property
170
+ def names(self) -> set[str]:
171
+ return set(self._tools)
172
+
173
+ def all(self) -> list[Tool]:
174
+ """所有已注册工具(按名排序)。`getAllTools()` 与诊断面用它。"""
175
+ return [self._tools[n] for n in sorted(self._tools)]
176
+
177
+ def resolve(self, names: list[str]) -> list[Tool]:
178
+ return [self._tools[n] for n in names]
179
+
180
+ def llm_schemas(self, names: list[str] | None = None) -> list[dict]:
181
+ source = [self._tools[n] for n in names] if names is not None else list(self._tools.values())
182
+ return [t.to_llm_schema() for t in source]
183
+ class CapabilityRegistry:
184
+ """汇总所有已发现扩展的消费型配置能力(动态装载门控)。
185
+
186
+ 同时也是**能力交接**的汇合点(E20):提供方 `add_resolver`,消费方 `resolve_tools`。
187
+ 两半住在一起是因为它们回答的是同一个问题 —— “这个种类的配置,谁管、怎么变成能用的东西”。
188
+ """
189
+
190
+ def __init__(self) -> None:
191
+ self._providers: dict[str, set[str]] = {} # kind -> {extension_name}
192
+ self._types: dict[str, set[str]] = {} # kind -> {type…}
193
+ self._resolvers: dict[str, list[tuple[str, Any]]] = {} # kind -> [(扩展名, fn)]
194
+
195
+ def merge(self, api: ExtensionApi) -> None:
196
+ for kind in api.config_kinds:
197
+ self._providers.setdefault(kind, set()).add(api._name)
198
+ if kind in api._types:
199
+ self._types.setdefault(kind, set()).update(api._types[kind])
200
+
201
+ # ── 能力交接:提供方声明怎么解析 ──
202
+ def add_resolver(self, kind: str, source: str, resolver: Any) -> None:
203
+ """登记一个解析器。`source` 是扩展名(诊断时要指得到人)。
204
+
205
+ **直接写进汇合点,不靠 `merge`** —— 与 `CommandRegistry` / `FlagRegistry` 同一模式:
206
+ 否则 A 扩展在自己 `register()` 里 `resolveTools` 就看不到 B 刚登记的东西(顺序耦合)。
207
+ """
208
+ self._resolvers.setdefault(kind, []).append((source, resolver))
209
+
210
+ def has_resolver(self, kind: str) -> bool:
211
+ return bool(self._resolvers.get(kind))
212
+
213
+ async def resolve_tools(self, kind: str, *, scope: Any = None) -> list[Any]:
214
+ """问所有 `kind` 的提供者要工具,按登记顺序合并。
215
+
216
+ - **没提供者 → `[]`**:这就是 E20 选“能力交接”而不是直接 `import` 的全部好处 ——
217
+ 装 qi-agents 不装 qi-mcp 时角色照跑,只是没 MCP 工具;
218
+ - 单薄返回也宽容(返回一个 Tool 而不是列表也能用 —— 写扩展的人常这么干);
219
+ - **提供者自己的失败不在这里捕**:一个 MCP server 起不来是 qi-mcp 的事(它该报 note
220
+ 并跳过那个 server),不是每个消费方都要重新决定一遍。
221
+ """
222
+ tools: list[Any] = []
223
+ for _source, resolver in self._resolvers.get(kind, ()):
224
+ got = resolver(scope=scope)
225
+ if inspect.isawaitable(got):
226
+ got = await got
227
+ if got is None:
228
+ continue
229
+ tools.extend(got if isinstance(got, (list, tuple)) else [got])
230
+ return tools
231
+
232
+ def providers(self, kind: str) -> set[str]:
233
+ """哪些扩展在管这个种类(`qi doctor` 用:反查"宿主真收到了什么")。"""
234
+ return set(self._providers.get(kind, ()))
235
+
236
+ def has_provider(self, kind: str) -> bool:
237
+ return kind in self._providers and bool(self._providers[kind])
238
+
239
+ def types(self, kind: str) -> set[str]:
240
+ return set(self._types.get(kind, ()))
241
+
242
+ @property
243
+ def kinds(self) -> list[str]:
244
+ return sorted(self._providers)
245
+
246
+
247
+ def discover_extensions(catalog: ToolCatalog, capabilities: CapabilityRegistry,
248
+ cwd: Path | None = None, *,
249
+ bus: ExtensionBus,
250
+ host: Any = None,
251
+ commands: CommandRegistry | None = None,
252
+ flags: FlagRegistry | None = None,
253
+ cli_commands: CliCommandRegistry | None = None,
254
+ renderers: Any = None,
255
+ on_warning: Callable[[str], None] | None = None,
256
+ extra_dirs: Iterable[Path | tuple[Path, str]] | None = None,
257
+ project_trusted: bool = True,
258
+ no_discovery: bool = False,
259
+ only_project: bool = False) -> list[str]:
260
+ """发现并装载扩展:目录通道 + entry points(`qi.extensions`)。返回扩展名列表。
261
+
262
+ `bus` **必填**:扩展能力的一半是订阅事件,没总线的装载等于装了个哑巴
263
+ (工具能注册、`on()` 却无处可去)—— 宁可在调用点报 TypeError,不要静默丢掉 handler。
264
+
265
+ `host` 是工具集读写面(`setActiveTools` / `getActiveTools` 的后端)。**选填**:
266
+ 没给时 `getActiveTools` 退回“catalog 里的全部”,而 `setActiveTools` 会**报错**
267
+ (不是静默无效)—— 失败看得见,所以不必像 bus 那样强制。
268
+
269
+ `on_warning` 收“装上了但有隐患”的报告(目前只有一件:扩展把宿主写进了依赖)。
270
+ 它**不阻止装载** —— 那类问题的现场在 pip 的解析结果里,不在这一行,所以要做的是
271
+ 让用户知道,而不是把扩展判死。
272
+
273
+ `commands` / `flags` 是扩展向宿主登记的入口(命令与快捷键 / CLI 旗标),与
274
+ `capabilities` 并列。**没给对应的汇合点时会报错**(不是静默无效):
275
+ `registerCommand` / `registerFlag` 注册了却没人收,扩展会以为它生效了。
276
+ `capabilities` 同时是**能力交接**的汇合点(`registerResolver` / `resolveTools`,E20):
277
+ 它是必填参数,所以提供方与消费方总能互相看见。
278
+
279
+ 优先级(先到先得,同名跳过):项目 `.qi/extensions/` → 全局 `<agent>/extensions/`
280
+ → `extra_dirs`(settings.json 的 `extensions[]`)→ entry points。
281
+
282
+ `extra_dirs` 每项可以是 `Path`(作用域记 `temporary`)或 `(Path, scope)`;
283
+ `scope` 就是 `source_info.scope`,用于 `getAllTools()` 过滤与诊断。
284
+
285
+ `project_trusted=False` 时**不扫项目目录** —— 扩展是仓库控制的任意代码,
286
+ 未信任就不能执行(docs/extensions.md §2)。`extra_dirs` 由调用方负责:
287
+ 从**项目级** settings 解析出来的路径要自己在未信任时不传。
288
+
289
+ `no_discovery=True`(CLI 的 `--no-extensions`)关掉**发现**:entry point、内建目录、
290
+ `settings.extensions[]` 都不看,只加载 `extra_dirs` 里显式给的 —— 对齐 pi 的
291
+ “`--no-extensions` 关发现,但 `-e` 给的仍然生效”。
292
+ """
293
+ loaded: list[str] = []
294
+ for name, load, origin in _iter_extension_loaders(cwd, extra_dirs, project_trusted,
295
+ discovered=not no_discovery,
296
+ only_project=only_project):
297
+ try:
298
+ api = ExtensionApi(catalog=catalog, bus=bus, _name=name,
299
+ _path=origin["path"], _scope=origin["scope"],
300
+ _origin=origin["origin"], _host=host,
301
+ _capabilities=capabilities,
302
+ _commands=commands, _flags=flags,
303
+ _renderers=renderers,
304
+ _cli_commands=cli_commands)
305
+ module = load()
306
+ register = getattr(module, "register", None)
307
+ if not callable(register):
308
+ # **entry point 直接指向可调用对象是标准写法**(`包:register`):
309
+ # 这时 `load()` 返回的就是那个函数,不是模块。
310
+ #
311
+ # 这里**不能静默跳过**:静默跳过会让"官方扩展装了却不生效"变得完全不可见
312
+ # —— 三个官方扩展的 entry point 全是 `包:register` 形态,而这条 bug 正是
313
+ # 被 `qi doctor`(它什么也没列出来)抓出来的。
314
+ if callable(module):
315
+ register = module
316
+ else:
317
+ raise RuntimeError(
318
+ f"入口没有给出 register:拿到 {type(module).__name__}"
319
+ "(entry point 应写成 `包:register`,或指向一个带 register 的模块)")
320
+ register(api)
321
+ capabilities.merge(api)
322
+ warn_host_dependency(name, origin.get("dist"), on_warning,
323
+ script=origin.get("path"))
324
+ loaded.append(name)
325
+ except Exception as exc: # 扩展坏 → 启动报错(不静默)
326
+ raise RuntimeError(f"扩展 {name} 装载失败: {exc}") from exc
327
+ return loaded
328
+
329
+
330
+ def _normalize_extra(extra_dirs: Iterable[Path | tuple[Path, str]] | None
331
+ ) -> list[tuple[Path, str]]:
332
+ """附加路径归一:`(path, scope)` 原样过,裸 `Path` 记 `temporary`。
333
+
334
+ 允许裸 `Path` 是为了 `qi -e ./my-ext` 这种一次性试用能写成一行。
335
+ """
336
+ out: list[tuple[Path, str]] = []
337
+ for item in extra_dirs or ():
338
+ if isinstance(item, tuple):
339
+ path, scope = item
340
+ out.append((Path(path), scope))
341
+ else:
342
+ out.append((Path(item), "temporary"))
343
+ return out
344
+
345
+
346
+ def _normalize_dist(name: str) -> str:
347
+ """包名归一(与 `packages.normalize_name` / 依赖检查同一规则)。"""
348
+ import re
349
+
350
+ return re.sub(r"[-_.]+", "-", name or "").strip().lower()
351
+
352
+
353
+ def _dist_without_extensions(cwd: Path | None) -> set[str]:
354
+ """声明里写了 `extensions: []` 的**包名**(归一)—— 它们的 entry point 不加载。
355
+
356
+ 这是 pi 的 packages 对象形态(`docs/settings.md` 的 packages 一节)在 qi 的落点:
357
+ 目录扩展用列表里的否定项关,而 **pip 装进来的包没有路径可否定**,所以按包名关。
358
+ `discover_declared` 已经做过项目覆盖用户那层合并,所以这里就是最终结果。
359
+ """
360
+ from .packages import discover_declared
361
+
362
+ try:
363
+ declared, _unparsed = discover_declared(cwd)
364
+ except Exception: # noqa: BLE001 读设置失败在别处会报;这里只是过滤
365
+ return set()
366
+ return {d.name for d in declared if d.channel == "pip" and not d.contributes_extensions}
367
+
368
+
369
+ def _resolve(path: Path) -> Path:
370
+ """比较用的绝对路径(失败就退原样 —— 不因为解析不了就把排除项当成没写)。"""
371
+ try:
372
+ return path.resolve()
373
+ except OSError:
374
+ return path
375
+
376
+
377
+ def _excluded_extension_paths(cwd: Path | None, project_trusted: bool) -> set[Path]:
378
+ """两个作用域 `settings.extensions` 里的排除项(`!pat` / `-path`),解析成绝对路径。
379
+
380
+ 项目那份只在信任时算 —— 与纳入侧同一条门控(它随仓库走)。
381
+ """
382
+ from .settings import SettingsError, load_settings_by_scope, settings_exclude_paths
383
+
384
+ try:
385
+ scopes = load_settings_by_scope(cwd)
386
+ except SettingsError:
387
+ # settings 坏了会在别处报出来(runtime 装载 / clinician 的 doctor 节)。
388
+ # 这里只是拿排除项 —— 不能因为读不到它就把扩展发现整个搞挂。
389
+ return set()
390
+ out: set[Path] = set()
391
+ for scope in ("user", "project"):
392
+ if scope == "project" and not project_trusted:
393
+ continue
394
+ for path in settings_exclude_paths(scopes.get(scope), scope, "extensions", cwd):
395
+ out.add(_resolve(path))
396
+ return out
397
+
398
+
399
+ def _iter_extension_loaders(cwd: Path | None,
400
+ extra_dirs: Iterable[Path | tuple[Path, str]] | None = None,
401
+ project_trusted: bool = True, *,
402
+ discovered: bool = True,
403
+ only_project: bool = False):
404
+ """`(name, loader, origin)` 生成器:项目目录 + 全局目录 + 附加目录 + entry point。
405
+
406
+ `origin` = `{path, scope, origin}`,直接进工具的 `source_info` —— 所以
407
+ “这个工具是谁装的、从哪来的”在注册那一刻就固定了,不靠事后回查。
408
+
409
+ `discovered=False` 只走 `extra_dirs`(见 `discover_extensions` 的 `no_discovery`)。
410
+ """
411
+ import importlib.metadata as metadata
412
+
413
+ seen: set[str] = set()
414
+ # 排除项(`settings.extensions` 里的 `!pat` / `-path`)作用于**整个发现集** ——
415
+ # 与技能同一口径:内建目录里扫出来的扩展也能被单独关掉。
416
+ # (`settings_exclude_paths` 的 docstring 早就这么写了,但扩展这边一直没接上 ——
417
+ # 于是 `"extensions": ["-~/.qi/agent/extensions/foo"]` 是个**静默空操作**。)
418
+ excluded = _excluded_extension_paths(cwd, project_trusted) if discovered else set()
419
+ # pip 包没有路径可否定 —— 它们的"关掉"是对象形态里的 `extensions: []`(pi 的做法)
420
+ disabled_dists = _dist_without_extensions(cwd) if discovered else set()
421
+ # (目录, 是否允许该目录本身就是扩展, scope, 是否**显式**给出)。内建目录:项目那一档受信任门控
422
+ roots: list[tuple[Path, bool, str, bool]] = []
423
+ if discovered:
424
+ if project_trusted:
425
+ roots.append((paths.project_home(cwd) / paths.EXTENSIONS_DIR_NAME, False,
426
+ "project", False))
427
+ if not only_project:
428
+ roots.append((paths.global_home() / paths.EXTENSIONS_DIR_NAME, False, "user", False))
429
+ # 附加路径两种都行(见下):可以是父目录,也可以直接是一个扩展目录。
430
+ # 它们是**显式**给的(`-e` / CLI),所以排除项**不管**它们 —— 显式意图胜过配置。
431
+ roots += [(path, True, scope, True) for path, scope in _normalize_extra(extra_dirs)]
432
+ for root, allow_self, scope, explicit in roots:
433
+ if not root.is_dir():
434
+ continue
435
+ # `settings.extensions` 的一条可以直接指向**单个扩展目录**(对齐 pi:
436
+ # `"extensions": ["/path/to/local/extension.ts", "…/extension/dir"]`)。
437
+ # 内建目录只取后者,避免 `.qi/extensions/extension.py` 被当成一个叫
438
+ # "extensions" 的扩展。
439
+ if allow_self and (root / EXTENSION_ENTRY_FILE).is_file():
440
+ children: list[Path] = [root]
441
+ else:
442
+ children = sorted(root.iterdir())
443
+ for child in children:
444
+ entry = child / EXTENSION_ENTRY_FILE
445
+ if not (child.is_dir() and entry.is_file()):
446
+ continue
447
+ if not explicit and (_resolve(child) in excluded or _resolve(entry) in excluded):
448
+ continue
449
+ name = child.name
450
+ if name in seen:
451
+ continue
452
+ seen.add(name)
453
+ yield name, _make_file_loader(name, entry), {
454
+ "path": str(entry), "scope": scope, "origin": "top-level"} # entry point 通道(pip 包)
455
+ for ep in (metadata.entry_points(group=EXTENSION_ENTRY_POINT_GROUP)
456
+ if discovered and not only_project else ()):
457
+ name = ep.name
458
+ if name in seen:
459
+ continue
460
+ # 包通道的来源靠 distribution 定位;拿不到就留空(dist_info 缺失等)
461
+ dist = getattr(ep, "dist", None)
462
+ dist_name = getattr(dist, "name", None) if dist is not None else None
463
+ if dist_name and _normalize_dist(dist_name) in disabled_dists:
464
+ # 声明里写了 `extensions: []` —— 这个包不贡献扩展(它可能还在贡献别的)
465
+ continue
466
+ seen.add(name)
467
+ path = str(getattr(dist, "_path", "") or "") if dist is not None else ""
468
+ yield name, ep.load, {"path": path, "scope": "user", "origin": "package",
469
+ "dist": dist}
470
+
471
+
472
+ def _make_file_loader(name: str, entry: Path):
473
+ def loader():
474
+ spec = importlib.util.spec_from_file_location(f"qi_extension_{name}", entry)
475
+ assert spec and spec.loader
476
+ module = importlib.util.module_from_spec(spec)
477
+ # 让扩展能 import 兄弟模块(轻量目录通道支持)
478
+ sys.path.insert(0, str(entry.parent))
479
+ try:
480
+ spec.loader.exec_module(module)
481
+ finally:
482
+ sys.path.remove(str(entry.parent))
483
+ return module
484
+
485
+ return loader