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
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "dark",
4
+ "vars": {
5
+ "cyan": "#00d7ff",
6
+ "blue": "#5f87ff",
7
+ "green": "#b5bd68",
8
+ "red": "#cc6666",
9
+ "yellow": "#ffff00",
10
+ "text": "#d4d4d4",
11
+ "gray": "#808080",
12
+ "dimGray": "#666666",
13
+ "darkGray": "#505050",
14
+ "accent": "#8abeb7",
15
+ "selectedBg": "#3a3a4a",
16
+ "userMsgBg": "#343541",
17
+ "toolPendingBg": "#282832",
18
+ "toolSuccessBg": "#283228",
19
+ "toolErrorBg": "#3c2828",
20
+ "customMsgBg": "#2d2838"
21
+ },
22
+ "colors": {
23
+ "accent": "accent",
24
+ "border": "blue",
25
+ "borderAccent": "cyan",
26
+ "borderMuted": "darkGray",
27
+ "success": "green",
28
+ "error": "red",
29
+ "warning": "yellow",
30
+ "muted": "gray",
31
+ "dim": "dimGray",
32
+ "text": "text",
33
+ "thinkingText": "gray",
34
+
35
+ "selectedBg": "selectedBg",
36
+ "scrollbarThumb": "selectedBg",
37
+ "searchMatchBg": "selectedBg",
38
+ "searchMatchText": "text",
39
+ "userMessageBg": "userMsgBg",
40
+ "userMessageText": "text",
41
+ "customMessageBg": "customMsgBg",
42
+ "customMessageText": "text",
43
+ "customMessageLabel": "#9575cd",
44
+ "toolPendingBg": "toolPendingBg",
45
+ "toolSuccessBg": "toolSuccessBg",
46
+ "toolErrorBg": "toolErrorBg",
47
+ "toolTitle": "text",
48
+ "toolOutput": "gray",
49
+
50
+ "mdHeading": "#f0c674",
51
+ "mdLink": "#81a2be",
52
+ "mdLinkUrl": "dimGray",
53
+ "mdCode": "accent",
54
+ "mdCodeBlock": "green",
55
+ "mdCodeBlockBorder": "gray",
56
+ "mdQuote": "gray",
57
+ "mdQuoteBorder": "gray",
58
+ "mdHr": "gray",
59
+ "mdListBullet": "accent",
60
+
61
+ "toolDiffAdded": "green",
62
+ "toolDiffRemoved": "red",
63
+ "toolDiffContext": "gray",
64
+
65
+ "syntaxComment": "#6A9955",
66
+ "syntaxKeyword": "#569CD6",
67
+ "syntaxFunction": "#DCDCAA",
68
+ "syntaxVariable": "#9CDCFE",
69
+ "syntaxString": "#CE9178",
70
+ "syntaxNumber": "#B5CEA8",
71
+ "syntaxType": "#4EC9B0",
72
+ "syntaxOperator": "#D4D4D4",
73
+ "syntaxPunctuation": "#D4D4D4",
74
+
75
+ "thinkingOff": "darkGray",
76
+ "thinkingMinimal": "#6e6e6e",
77
+ "thinkingLow": "#5f87af",
78
+ "thinkingMedium": "#81a2be",
79
+ "thinkingHigh": "#b294bb",
80
+ "thinkingXhigh": "#d183e8",
81
+ "thinkingMax": "#ff5fff",
82
+
83
+ "bashMode": "green"
84
+ },
85
+ "export": {
86
+ "pageBg": "#18181e",
87
+ "cardBg": "#1e1e24",
88
+ "infoBg": "#3c3728"
89
+ }
90
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "light",
4
+ "vars": {
5
+ "teal": "#5a8080",
6
+ "blue": "#547da7",
7
+ "green": "#588458",
8
+ "red": "#aa5555",
9
+ "yellow": "#9a7326",
10
+ "text": "#1f2328",
11
+ "mediumGray": "#6c6c6c",
12
+ "dimGray": "#767676",
13
+ "lightGray": "#b0b0b0",
14
+ "selectedBg": "#d0d0e0",
15
+ "userMsgBg": "#e8e8e8",
16
+ "toolPendingBg": "#e8e8f0",
17
+ "toolSuccessBg": "#e8f0e8",
18
+ "toolErrorBg": "#f0e8e8",
19
+ "customMsgBg": "#ede7f6"
20
+ },
21
+ "colors": {
22
+ "accent": "teal",
23
+ "border": "blue",
24
+ "borderAccent": "teal",
25
+ "borderMuted": "lightGray",
26
+ "success": "green",
27
+ "error": "red",
28
+ "warning": "yellow",
29
+ "muted": "mediumGray",
30
+ "dim": "dimGray",
31
+ "text": "text",
32
+ "thinkingText": "mediumGray",
33
+
34
+ "selectedBg": "selectedBg",
35
+ "scrollbarThumb": "selectedBg",
36
+ "searchMatchBg": "selectedBg",
37
+ "searchMatchText": "text",
38
+ "userMessageBg": "userMsgBg",
39
+ "userMessageText": "text",
40
+ "customMessageBg": "customMsgBg",
41
+ "customMessageText": "text",
42
+ "customMessageLabel": "#7e57c2",
43
+ "toolPendingBg": "toolPendingBg",
44
+ "toolSuccessBg": "toolSuccessBg",
45
+ "toolErrorBg": "toolErrorBg",
46
+ "toolTitle": "text",
47
+ "toolOutput": "mediumGray",
48
+
49
+ "mdHeading": "yellow",
50
+ "mdLink": "blue",
51
+ "mdLinkUrl": "dimGray",
52
+ "mdCode": "teal",
53
+ "mdCodeBlock": "green",
54
+ "mdCodeBlockBorder": "mediumGray",
55
+ "mdQuote": "mediumGray",
56
+ "mdQuoteBorder": "mediumGray",
57
+ "mdHr": "mediumGray",
58
+ "mdListBullet": "green",
59
+
60
+ "toolDiffAdded": "green",
61
+ "toolDiffRemoved": "red",
62
+ "toolDiffContext": "mediumGray",
63
+
64
+ "syntaxComment": "#008000",
65
+ "syntaxKeyword": "#0000FF",
66
+ "syntaxFunction": "#795E26",
67
+ "syntaxVariable": "#001080",
68
+ "syntaxString": "#A31515",
69
+ "syntaxNumber": "#098658",
70
+ "syntaxType": "#267F99",
71
+ "syntaxOperator": "#000000",
72
+ "syntaxPunctuation": "#000000",
73
+
74
+ "thinkingOff": "lightGray",
75
+ "thinkingMinimal": "#767676",
76
+ "thinkingLow": "blue",
77
+ "thinkingMedium": "teal",
78
+ "thinkingHigh": "#875f87",
79
+ "thinkingXhigh": "#8b008b",
80
+ "thinkingMax": "#af005f",
81
+
82
+ "bashMode": "green"
83
+ },
84
+ "export": {
85
+ "pageBg": "#f8f8f8",
86
+ "cardBg": "#ffffff",
87
+ "infoBg": "#fffae6"
88
+ }
89
+ }
qi_agent/titling.py ADDED
@@ -0,0 +1,90 @@
1
+ """会话自动命名:用**模型**给会话起一个短标题。
2
+
3
+ 为什么需要它:qi 的会话默认叫「未命名」,左栏一列「未命名」等于没有左栏 —— 分不清哪条是哪条。
4
+ 标题是**检索线索**,而生成它只需要一次很短的 LLM 调用(输入是用户的第一句话)。
5
+
6
+ 三条设计取舍:
7
+
8
+ 1. **只命名一次**:`title` 非空就不再调(用户手改过的标题绝不被覆盖)。
9
+ 2. **纯函数收尾**:模型永远可能带上引号、井号、`标题:` 前缀、多行解释 —— `clean_title` 负责
10
+ 把它压成一行干净的标题,并且**只在这些清洗之后**才落盘(见那几条单测)。
11
+ 3. **失败无声**:命名失败(超时/报错/返回垃圾)一律回落 `None`,**绝不**影响本轮回答 ——
12
+ 少一个标题远好过多一个错误。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .llm import ChatMessage, LLMClient
18
+
19
+ #: 给模型看的约束。刻意写"只输出标题本身":多数模型会顺手加上引号和句末标点。
20
+ TITLE_SYSTEM_PROMPT = """你负责给一段对话起标题。规则:
21
+
22
+ - 只输出标题本身:不要引号、不要井号、不要句末标点、不要任何解释
23
+ - 用与用户消息相同的语言,中文不超过 12 个字,英文不超过 8 个词
24
+ - 概括用户**想做什么**(动作 + 对象),不要复述原句、不要写"关于…的对话"
25
+
26
+ 示例:
27
+ 用户:帮我看看这个仓库的结构,顺便说下每个目录干什么
28
+ 标题:梳理仓库结构"""
29
+
30
+ #: 标题长度上限(字符)。超出就截断 —— 左栏那一行本来也会省略号截断。
31
+ TITLE_MAX_CHARS = 30
32
+
33
+ #: 输入截断:标题只看开头,没必要把整条长消息喂进去。
34
+ TITLE_INPUT_CHARS = 600
35
+
36
+ #: 这些是"等于没起"的答案:模型偶尔会回它,写上反而占住名分。
37
+ _USELESS = {"未命名", "untitled", "none", "n/a", "无", "标题"}
38
+
39
+
40
+ def clean_title(raw: str | None) -> str | None:
41
+ """把模型的原始输出压成一行标题;**拿不准就返回 `None`**(宁可不命名)。
42
+
43
+ 这一步是必需的,不是美化:实测模型会回 `标题:"梳理仓库结构"`、`### 仓库结构梳理`、
44
+ 甚至先写一句"这个对话是关于…的"。这些直接落盘,左栏里就是一行markdown 残渣。
45
+ """
46
+ if not raw:
47
+ return None
48
+ # 第一行有效内容(模型常先来一句解释再给标题)
49
+ line = next((ln.strip() for ln in raw.splitlines() if ln.strip()), "")
50
+ if not line:
51
+ return None
52
+ # 去掉 `标题:` / `Title:` 这类前缀
53
+ for prefix in ("标题:", "标题:", "title:", "Title:"):
54
+ if line.startswith(prefix):
55
+ line = line[len(prefix):].strip()
56
+ # ATX 标题的井号是**前缀**(`### 标题 ##`),不是成对装饰 —— 单独剥。
57
+ line = line.strip("#").strip()
58
+ # 去掉成对的引号 / 反引号 / 星号等装饰(可能有嵌套,循环剥)
59
+ for _ in range(4):
60
+ before = line
61
+ line = line.strip()
62
+ for pair in (('"', '"'), ("'", "'"), ("“", "”"), ("「", "」"),
63
+ ("`", "`"), ("**", "**"), ("*", "*")):
64
+ if len(line) > 2 * len(pair[0]) and line.startswith(pair[0]) and line.endswith(pair[1]):
65
+ line = line[len(pair[0]):-len(pair[1])].strip()
66
+ if line == before:
67
+ break
68
+ line = " ".join(line.split()) # 折叠内部空白
69
+ line = line.rstrip("。.!,;:,、") # 句末标点
70
+ if not line or line.strip().lower() in _USELESS:
71
+ return None
72
+ return line[:TITLE_MAX_CHARS]
73
+
74
+
75
+ async def suggest_title(llm: LLMClient, first_user_text: str) -> str | None:
76
+ """让模型给一个会话标题。
77
+
78
+ **不抛异常**(调用方在流式回合里 await 它):拿不到就回 `None`,那一轮照常结束。
79
+ """
80
+ text = (first_user_text or "").strip()[:TITLE_INPUT_CHARS]
81
+ if not text:
82
+ return None
83
+ try:
84
+ resp = await llm.chat([ChatMessage(role="system", content=TITLE_SYSTEM_PROMPT),
85
+ ChatMessage(role="user", content=text)])
86
+ except Exception: # noqa: BLE001 命名失败不该影响回答
87
+ return None
88
+ if getattr(resp, "tool_calls", None):
89
+ return None # 它想调工具就说明没在起标题
90
+ return clean_title(getattr(resp, "text", None))
@@ -0,0 +1,349 @@
1
+ """内置 8 工具 + clarify(对齐 docs/how-qi-works.md):read/ls/find/grep/write/edit/bash/powershell。
2
+
3
+ 安全:六个文件工具的路径限制在会话工作目录内。bash / powershell 与 pi 一致 —— **不做命令级过滤**
4
+ (不筛子命令、不拦重定向),限制只来自工具级收窄(tools / disallowed_tools)或容器/VM。
5
+ 见 docs/security.md 与 design/bash-allowlist.md。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import os
12
+ import re
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..abort import AbortSignal
19
+ from ..extensions import ExtensionUi, Tool, ToolError, register_tool
20
+ from ..models import TOOL_ERROR, TOOL_OK, ToolOutcome
21
+ from .shell import (
22
+ POWERSHELL_UTF8_PREFIX,
23
+ ShellError,
24
+ ShellResult,
25
+ resolve_powershell_config,
26
+ resolve_shell_config,
27
+ run_shell,
28
+ )
29
+
30
+ MAX_RESULT = 200 # 截断行数
31
+ MAX_FILE_CHARS = 50_000 # read 默认截断字符
32
+
33
+ # clarify 回调签名:async (text) -> str | None
34
+ AskFn = Callable[[str], Awaitable[str | None]]
35
+
36
+
37
+ @dataclass
38
+ class ToolContext:
39
+ agent_name: str
40
+ workdir: Path # 会话工作目录(路径边界)
41
+ data_sources: list = field(default_factory=list)
42
+ ask: AskFn | None = None # async (text)->str|None,供 clarify 用
43
+ shell_path: str | None = None # settings.shellPath:显式指定 bash(对齐 pi)
44
+ abort: AbortSignal | None = None # 本回合的中断信号(runner 按回合注入)
45
+ #: 扩展工具要问人时的入口。**总是存在**(没前端就按 default 回答),与 handler 侧的
46
+ #: `ctx.ui` 同一套规则 —— 否则“扩展工具不能问人”会变成一个说不清的例外。
47
+ ui: ExtensionUi = field(default_factory=ExtensionUi)
48
+ #: 本项目是否已被信任(`-a` / 交互确认 / `defaultProjectTrust`)。
49
+ #: 工具**看得见**这件事,才能自己对“项目里带来的东西”设门 —— 否则每个要设门的
50
+ #: 扩展都会退化成相信自己写对了(而它无从知道)。写 qi-agents 时发现并加上:
51
+ #: 项目级角色是仓库控制的提示词,`subagent` 工具得先问一句。
52
+ project_trusted: bool = True
53
+ #: 本次调用的工具调用 id(pi 的 `execute(toolCallId, …)` 第 1 个参数)。
54
+ tool_call_id: str = ""
55
+ #: 流式进度回调(pi 的 `onUpdate`):`on_update(partial)` → 发 `tool_execution_update`
56
+ #: 事件。同步可调,工具不必 await。
57
+ on_update: Callable[[Any], None] | None = None
58
+ #: 这一回合的**会话环境变量**(`QI_MODEL` / `QI_SESSION_ID` …):bash / powershell
59
+ #: 会把它并进子进程环境(pi 的 `exposeSessionEnvironment`)。它同时是 agent
60
+ #: **自查当前模型**的唯一通道 —— 换模型是界面状态,不进对话上下文(见 docs/environment-variables.md)。
61
+ session_env: dict[str, str] = field(default_factory=dict)
62
+
63
+ def guard(self, p: str | Path) -> Path:
64
+ """路径必须落在 workdir 内(防越界,对齐 hikqin validate_path 思想)。"""
65
+ raw = Path(p).expanduser()
66
+ if raw.is_absolute():
67
+ resolved = raw.resolve(strict=False)
68
+ else:
69
+ resolved = (self.workdir / raw).resolve(strict=False)
70
+ root = self.workdir.resolve(strict=False)
71
+ if resolved != root and root not in resolved.parents:
72
+ raise ToolError(f"路径越界(仅允许会话目录内): {p}")
73
+ return resolved
74
+
75
+
76
+ def _truncate(text: str, limit: int = MAX_RESULT) -> str:
77
+ lines = text.splitlines()
78
+ if len(lines) <= limit:
79
+ return text
80
+ return "\n".join(lines[:limit]) + f"\n…(截断,共 {len(lines)} 行,前 {limit} 行)"
81
+
82
+
83
+ def _num_arg(args: dict, key: str, cast, default):
84
+ """取数值型工具参数:缺失/空 → default;非法 → ToolError(模型可读,不裸抛 ValueError)。"""
85
+ raw = args.get(key)
86
+ if raw is None or raw == "":
87
+ return default
88
+ try:
89
+ return cast(raw)
90
+ except (TypeError, ValueError):
91
+ raise ToolError(f"{key} 需要{cast.__name__}类型,收到 {raw!r}") from None
92
+
93
+
94
+ # ── read ─────────────────────────────────────────────
95
+
96
+ async def _read(args: dict, ctx: ToolContext) -> str:
97
+ path = ctx.guard(str(args["path"]))
98
+ if path.is_dir():
99
+ raise ToolError(f"{path} 是目录")
100
+ if not path.is_file():
101
+ raise ToolError(f"文件不存在: {path}")
102
+ start = _num_arg(args, "start_line", int, 1)
103
+ end = _num_arg(args, "end_line", int, 0)
104
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
105
+ total = len(lines)
106
+ if end <= 0:
107
+ end = total
108
+ if start < 1 or end < start:
109
+ raise ToolError("start_line ≥ 1 且 end_line ≥ start_line")
110
+ seg = lines[start - 1 : end]
111
+ shown = "\n".join(seg)
112
+ if sum(len(l) for l in seg) > MAX_FILE_CHARS:
113
+ shown = shown[:MAX_FILE_CHARS] + "\n…(内容过长已截断)"
114
+ head = f"# {path} (行 {start}-{min(end,total)}/{total})"
115
+ return _truncate(head + "\n" + shown, limit=MAX_RESULT)
116
+
117
+
118
+ # ── ls ───────────────────────────────────────────────
119
+
120
+ async def _ls(args: dict, ctx: ToolContext) -> str:
121
+ path = ctx.guard(str(args.get("path", ".")))
122
+ if not path.is_dir():
123
+ raise ToolError(f"不是目录: {path}")
124
+ try:
125
+ names = sorted(os.listdir(path))
126
+ except OSError as exc:
127
+ raise ToolError(f"无法列出目录: {exc}") from exc
128
+ rows = []
129
+ for n in names:
130
+ full = path / n
131
+ try:
132
+ kind = "d" if full.is_dir() else ("x" if os.access(full, os.X_OK) else "f")
133
+ except OSError: # 权限不足/坏软链:仍列出条目,退化为普通文件
134
+ kind = "f"
135
+ rows.append(f"{kind} {n}")
136
+ return _truncate(f"# {path} ({len(rows)} 项)\n" + "\n".join(rows))
137
+
138
+
139
+ # ── find(文件名/glob) ────────────────────────────────
140
+
141
+ async def _find(args: dict, ctx: ToolContext) -> str:
142
+ root = ctx.guard(str(args.get("path", ".")))
143
+ pattern = str(args.get("pattern", ""))
144
+ if not pattern:
145
+ raise ToolError("需要 pattern(名称或 glob,如 *.py)")
146
+ hits: list[Path] = []
147
+ for base, _dirs, files in os.walk(root):
148
+ if ".git" in base.split(os.sep):
149
+ continue
150
+ for f in files:
151
+ if fnmatch.fnmatch(f, pattern):
152
+ hits.append(Path(base) / f)
153
+ rel = [str(p.relative_to(ctx.workdir)) if ctx.workdir in p.parents else str(p) for p in hits]
154
+ return _truncate(f"# 匹配 {pattern}: {len(rel)} 个\n" + "\n".join(sorted(rel)))
155
+
156
+
157
+ # ── grep(正则 + 行号 + 上下文) ───────────────────────
158
+
159
+ async def _grep(args: dict, ctx: ToolContext) -> str:
160
+ root = ctx.guard(str(args.get("path", ".")))
161
+ pattern = str(args.get("pattern", ""))
162
+ if not pattern:
163
+ raise ToolError("需要 pattern")
164
+ regex = re.compile(pattern, re.IGNORECASE if args.get("ignore_case") else 0)
165
+ context = _num_arg(args, "context", int, 0)
166
+ max_count = _num_arg(args, "max_count", int, 100)
167
+ matches: list[str] = []
168
+ files = [root] if root.is_file() else sorted(
169
+ p for p in root.rglob("*") if p.is_file() and ".git" not in p.parts
170
+ )
171
+ for f in files:
172
+ try:
173
+ lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
174
+ except OSError:
175
+ continue
176
+ for idx, line in enumerate(lines):
177
+ if regex.search(line):
178
+ rel = str(f.relative_to(ctx.workdir)) if ctx.workdir in f.parents else str(f)
179
+ matches.append(f"{rel}:{idx+1}: {line.strip()[:500]}")
180
+ for off in range(1, context + 1):
181
+ if idx + off < len(lines):
182
+ matches.append(f"{rel}:{idx+off+1}: {lines[idx+off].strip()[:500]}")
183
+ if len(matches) >= max_count:
184
+ return _truncate(f"# 命中 {pattern}(已达 {max_count} 条上限)\n" + "\n".join(matches))
185
+ return _truncate(f"# 命中 {pattern}: {len(matches)}\n" + "\n".join(matches))
186
+
187
+
188
+ # ── write ────────────────────────────────────────────
189
+
190
+ async def _write(args: dict, ctx: ToolContext) -> str:
191
+ path = ctx.guard(str(args["path"]))
192
+ content = str(args.get("content", ""))
193
+ if path.is_dir():
194
+ raise ToolError(f"{path} 是目录")
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ existed = path.exists()
197
+ path.write_text(content, encoding="utf-8")
198
+ return f"{'已覆盖' if existed else '已新建'} {path} ({len(content)} 字符)"
199
+
200
+
201
+ # ── edit(diff 精确编辑:old_text→new_text 唯一匹配) ───
202
+
203
+ async def _edit(args: dict, ctx: ToolContext) -> str:
204
+ path = ctx.guard(str(args["path"]))
205
+ old_text = str(args.get("old_text", ""))
206
+ new_text = str(args.get("new_text", ""))
207
+ if not old_text:
208
+ raise ToolError("edit 需要 old_text(精确匹配)")
209
+ text = path.read_text(encoding="utf-8")
210
+ count = text.count(old_text)
211
+ if count == 0:
212
+ raise ToolError(f"old_text 未匹配到(检查空白/缩进): {old_text[:80]!r}")
213
+ if count > 1:
214
+ raise ToolError(f"old_text 匹配到 {count} 处,请扩大上下文使其唯一")
215
+ path.write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
216
+ return f"已编辑 {path}:替换 1 处,{len(old_text)}→{len(new_text)} 字符"
217
+
218
+
219
+ # ── 命令执行:bash / powershell(与 pi 对齐:真 shell + 无命令级过滤) ──
220
+
221
+
222
+ def _shell_outcome(result: ShellResult, timeout: float) -> ToolOutcome:
223
+ """两个 shell 工具共用的结果收敛 —— 文本形态与旧版**逐字一致**。"""
224
+ if result.aborted:
225
+ return ToolOutcome(status=TOOL_ERROR, error="aborted",
226
+ result="操作已中断(用户中止本回合)")
227
+ if result.timed_out:
228
+ return ToolOutcome(status=TOOL_ERROR, error="timeout",
229
+ result=f"命令超时(>{timeout:.0f}s),已终止")
230
+ rc = result.exit_code
231
+ if rc == 0:
232
+ return ToolOutcome(status=TOOL_OK, result=_truncate(result.text), exit_code=0)
233
+ return ToolOutcome(status=TOOL_ERROR, result=f"exit={rc}\n{_truncate(result.text)}", exit_code=rc)
234
+
235
+
236
+ async def _bash(args: dict, ctx: ToolContext) -> ToolOutcome:
237
+ """执行 bash 命令 —— 解析**真正的 bash**,不是系统默认 shell(对齐 pi)。
238
+
239
+ `bash` 是唯一能上报退出码的工具:`result` 文本与旧版**逐字一致**(失败时保留
240
+ `exit=N` 前缀),模型看到的内容不变;`status` / `exit_code` 是给 UI 工具卡片用的
241
+ 结构化字段。
242
+
243
+ 安全:与 pi 一致,内置 bash **不筛命令**。要收紧就在 agent 级摘工具
244
+ (`disallowed_tools: [bash]`),要真边界就把进程放进容器/VM。
245
+ """
246
+ command = str(args.get("command", "")).strip()
247
+ if not command:
248
+ raise ToolError("bash 需要 command")
249
+ timeout = _num_arg(args, "timeout", float, 120.0)
250
+ try:
251
+ config = resolve_shell_config(ctx.shell_path)
252
+ except ShellError as exc:
253
+ raise ToolError(str(exc)) from None
254
+ result = await run_shell(config, command, cwd=ctx.workdir, timeout=timeout,
255
+ abort=ctx.abort, env=ctx.session_env)
256
+ return _shell_outcome(result, timeout)
257
+
258
+
259
+ async def _powershell(args: dict, ctx: ToolContext) -> ToolOutcome:
260
+ """Windows 原生通道:PowerShell(pi 的第 8 个工具)。
261
+
262
+ 非 Windows **不**静默降级成 bash —— 那会给出“命令确实跑过了”的假象;
263
+ 这里回一条能照做的错误(换 bash,或用 `pwsh -Command ...`)。
264
+ """
265
+ command = str(args.get("command", "")).strip()
266
+ if not command:
267
+ raise ToolError("powershell 需要 command")
268
+ timeout = _num_arg(args, "timeout", float, 120.0)
269
+ try:
270
+ config = resolve_powershell_config()
271
+ except ShellError as exc:
272
+ raise ToolError(str(exc)) from None
273
+ result = await run_shell(config, POWERSHELL_UTF8_PREFIX + command,
274
+ cwd=ctx.workdir, timeout=timeout, abort=ctx.abort,
275
+ env=ctx.session_env)
276
+ return _shell_outcome(result, timeout)
277
+
278
+
279
+ # ── clarify(通用小工具,B4:v1 内置) ──────────────────
280
+
281
+ async def _clarify(args: dict, ctx: ToolContext) -> str:
282
+ question = str(args.get("question", "请澄清:")).strip()
283
+ if ctx.ask is not None:
284
+ answer = await ctx.ask(question)
285
+ if answer:
286
+ return f"用户回答: {answer}"
287
+ return f"[需要用户澄清] {question}"
288
+
289
+
290
+ # ── 注册 ─────────────────────────────────────────────
291
+
292
+ def _schema(props: dict, required: list[str] | None = None) -> dict:
293
+ return {"type": "object", "properties": props, "required": required or []}
294
+
295
+
296
+ def register_builtin_tools(catalog) -> None:
297
+ """注册内置工具。
298
+
299
+ P-E2a **dogfood**:走的是扩展同一个 `register_tool`(不是 `catalog.register`),
300
+ 来源照 pi 的写法记 `source="builtin"` / `path="<builtin:名>"` —— 于是
301
+ `getAllTools()` 能用同一把尺子区分内置与扩展工具,而不需要额外维护一张名单。
302
+
303
+ `description` 进 tool schema(模型决定调不调时的说明),`prompt_snippet` 进系统
304
+ 提示词「可用工具」那一行(更短);`prompt_guidelines` 只在该工具启用时追加。
305
+ """
306
+ def add(tool: Tool) -> None:
307
+ register_tool(catalog, tool, source="builtin", path=f"<builtin:{tool.name}>",
308
+ scope="temporary", origin="top-level")
309
+
310
+ add(Tool("read", "读取文件内容,支持行范围。路径相对会话目录。", _schema(
311
+ {"path": {"type": "string", "description": "文件路径"},
312
+ "start_line": {"type": "integer"},
313
+ "end_line": {"type": "integer"}}, ["path"]), _read,
314
+ prompt_snippet="读取文件(可给行范围)",
315
+ prompt_guidelines=["用 read 读文件,不要用 bash 的 cat/sed/head/tail 代替(带行号且省 token)。"]))
316
+ add(Tool("ls", "列出目录内容(前缀 d=目录/f=文件)。", _schema(
317
+ {"path": {"type": "string"}}), _ls,
318
+ prompt_snippet="列出目录内容"))
319
+ add(Tool("find", "按文件名/glob 搜索(如 *.py)。", _schema(
320
+ {"path": {"type": "string"}, "pattern": {"type": "string"}}, ["pattern"]), _find,
321
+ prompt_snippet="按文件名/glob 找文件"))
322
+ add(Tool("grep", "正则搜索文件内容,返回 文件:行号:行。", _schema(
323
+ {"pattern": {"type": "string"}, "path": {"type": "string"}, "ignore_case": {"type": "boolean"},
324
+ "context": {"type": "integer"}, "max_count": {"type": "integer"}}, ["pattern"]), _grep,
325
+ prompt_snippet="正则搜文件内容"))
326
+ add(Tool("write", "整写文件(新建/覆盖)。", _schema(
327
+ {"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]), _write,
328
+ prompt_snippet="整写文件(新建/覆盖)",
329
+ prompt_guidelines=["用 write 建新文件;改已有文件用 edit。"]))
330
+ add(Tool("edit", "diff 精确编辑:old_text 必须唯一匹配后替换为 new_text。", _schema(
331
+ {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}},
332
+ ["path", "old_text", "new_text"]), _edit,
333
+ prompt_snippet="精确替换(唯一匹配)",
334
+ prompt_guidelines=["改已有文件优先用 edit(精确替换),不要用 write 整写覆盖。"]))
335
+ add(Tool("bash", "执行 bash 命令(工作目录=会话目录);输出截断 + timeout。", _schema(
336
+ {"command": {"type": "string"}, "timeout": {"type": "number"}}, ["command"]), _bash,
337
+ prompt_snippet="执行 bash 命令",
338
+ # pi 的 bash 也带这条:换模型是界面状态、不进上下文,所以环境变量是 agent
339
+ # **自查当前模型/会话**的唯一通道(见 docs/environment-variables.md)
340
+ prompt_guidelines=["你想确认当前模型/会话时,可以读 QI_* 环境变量"
341
+ "(QI_PROVIDER / QI_MODEL / QI_REASONING_LEVEL / QI_SESSION_ID)。"]))
342
+ add(Tool("powershell", "执行 PowerShell 命令(仅 Windows;输出走 UTF-8)。", _schema(
343
+ {"command": {"type": "string"}, "timeout": {"type": "number"}}, ["command"]), _powershell,
344
+ prompt_snippet="执行 PowerShell 命令(仅 Windows)",
345
+ prompt_guidelines=["你想确认当前模型/会话时,可以读 QI_* 环境变量"
346
+ "(QI_PROVIDER / QI_MODEL / QI_REASONING_LEVEL / QI_SESSION_ID)。"]))
347
+ add(Tool("clarify", "向用户提出澄清问题,等待回答。拿不准需求时使用。", _schema(
348
+ {"question": {"type": "string"}}, ["question"]), _clarify,
349
+ prompt_snippet="向用户澄清"))