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/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """qi(发行名 `qi-coding-agent`):编码 agent 框架 —— 单 agent core + 扩展。"""
2
+
3
+ import os
4
+
5
+ # LiteLLM 在 **import 时** 会去 GitHub 拉一份模型价目表
6
+ # (model_prices_and_context_window.json),超时 5 秒后失败才降级到本地备份。
7
+ # 那个请求对我们的模型(自定义 provider)没有价值,却会让每次启动白等 5 秒,
8
+ # 并在网络不通时打印一条吓人的 WARNING。
9
+ #
10
+ # 这里在 litellm 被 import 之前把它钉到本地备份:
11
+ # - qi_agent/__init__.py 一定先于任何子模块执行,而 litellm 是懒加载的
12
+ # (见 llm.py 的 `import litellm`),所以顺序有保证;
13
+ # - 用 setdefault:用户显式设 LITELLM_LOCAL_MODEL_COST_MAP=False 时尊重用户,
14
+ # 仍可拉远端最新价目表。
15
+ # 开关语义见 litellm/litellm_core_utils/get_model_cost_map.py。
16
+ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
17
+
18
+ __version__ = "0.1.0"
qi_agent/abort.py ADDED
@@ -0,0 +1,40 @@
1
+ """协作式中断信号(对齐 pi 的 `AbortSignal`)。
2
+
3
+ **为什么不用 `asyncio.Task.cancel()`**:取消会把整条 async generator 撕掉 ——
4
+ `QiRuntime.stream()` 末尾那次「助手消息落盘」就不会执行,用户屏幕上已经看过的半截回答
5
+ 在会话文件里消失(直播与回放不一致);工具子进程也只能靠 GC 兜(见 tools/shell.py 的
6
+ 进程组回收)。
7
+
8
+ 所以走**协作式**:信号在边界被检查(每轮开始、LLM 流式途中、每个工具调用前后),
9
+ runner 因此能走到正常收尾路径 —— 未执行的工具调用补上「已中断」结果(不让 `tool_calls`
10
+ 悬空)、已有文本照常落盘、照常发 `agent_end`。pi 的做法完全一致:被中断的工具调用返回一条
11
+ `"Operation aborted"` 结果,而不是把会话撕掉。
12
+
13
+ 两条入口都收敛到「回合有始有终」:
14
+ - TUI:`escape` → `AbortSignal.abort()`(再按一次才是强制 `cancel_all()`);
15
+ - Web:客户端 abort 请求 → ASGI 取消生成器 → `CancelledError`,这也是 `runtime.stream()`
16
+ 会就地收尾的一种情形(见 runtime.py)。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+
23
+
24
+ class AbortSignal:
25
+ """一次性中断信号:`abort()` 置位 / `aborted` 同步读 / `wait()` 异步等。"""
26
+
27
+ __slots__ = ("_event",)
28
+
29
+ def __init__(self) -> None:
30
+ self._event = asyncio.Event()
31
+
32
+ def abort(self) -> None:
33
+ self._event.set()
34
+
35
+ @property
36
+ def aborted(self) -> bool:
37
+ return self._event.is_set()
38
+
39
+ async def wait(self) -> None:
40
+ await self._event.wait()
qi_agent/auth.py ADDED
@@ -0,0 +1,216 @@
1
+ """凭证解析 + auth store(`~/.qi/agent/auth.json`,0600,对齐 pi)。
2
+
3
+ 解析顺序(对齐 pi):
4
+ 1. `~/.qi/agent/auth.json` 按 provider
5
+ 2. 约定环境变量(如 DEEPSEEK_API_KEY)
6
+ 3. provider 的 apiKey 引用(models.json 中字面量 / $ENV / !command)
7
+ 本地 provider(ollama)免 key。
8
+
9
+ `apiKey` 值语法(与 pi 一致):
10
+ - `!command` 执行命令取 stdout(不经 shell,见下)
11
+ - `$ENV` / `${ENV}` 环境变量插值;缺失则视为未解析
12
+ - `$$` / `$!` 转义为字面量 `$` / `!`
13
+ - 其它 字面量
14
+
15
+ 安全边界:`!command` 的内容来自**用户自己的配置文件**(与 auth.json 同一信任域),
16
+ 不会被 agent 或模型内容触发 —— 只在解析用户写的 `apiKey` 时求值。它按
17
+ `shlex` 拆成参数后以 `shell=False` 执行,所以没有 shell 注入面:
18
+
19
+ apiKey: "!security find-generic-password -ws 'anthropic'" # ✅ 引号由 shlex 处理
20
+ apiKey: "!op read 'op://vault/item/credential'" # ✅
21
+ apiKey: "!bash -lc 'cat /tmp/k | tr -d \\n'" # ✅ 需要管道时显式起 shell
22
+
23
+ 代价:不能直接写 `!cat a | jq -r .key`(管道会被当成普通参数)。这是有意的 ——
24
+ 要 shell 就把它写出来。
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import json
31
+ import os
32
+ import re
33
+ import shlex
34
+ import subprocess
35
+ from collections.abc import Mapping
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+ from .paths import global_home
40
+
41
+ AUTH_FILE_NAME = "auth.json"
42
+
43
+ # provider → 约定环境变量名(与 auth store 键一一对应)
44
+ # 国产 provider 那批与 `presets.py` 的 `api_key_env` 一一对应(有测试锁住两边一致)。
45
+ DEFAULT_API_KEY_ENV = {
46
+ "openai": "OPENAI_API_KEY",
47
+ "anthropic": "ANTHROPIC_API_KEY",
48
+ "deepseek": "DEEPSEEK_API_KEY",
49
+ "google": "GEMINI_API_KEY",
50
+ "gemini": "GEMINI_API_KEY",
51
+ "openrouter": "OPENROUTER_API_KEY",
52
+ "groq": "GROQ_API_KEY",
53
+ "mistral": "MISTRAL_API_KEY",
54
+ "xai": "XAI_API_KEY",
55
+ # 预置的国产 provider(见 presets.py)
56
+ "dashscope": "DASHSCOPE_API_KEY",
57
+ "moonshot": "MOONSHOT_API_KEY",
58
+ "zhipu": "ZHIPUAI_API_KEY",
59
+ "minimax": "MINIMAX_API_KEY",
60
+ "siliconflow": "SILICONFLOW_API_KEY",
61
+ "stepfun": "STEP_API_KEY",
62
+ "hunyuan": "HUNYUAN_API_KEY",
63
+ "volcengine": "ARK_API_KEY",
64
+ "mimo": "MIMO_API_KEY",
65
+ "qianfan": "QIANFAN_API_KEY",
66
+ }
67
+
68
+ KEYLESS_PROVIDERS = ("ollama",)
69
+
70
+ _ENV_RE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
71
+
72
+
73
+ def resolve_value(raw: str, env: Mapping[str, str] | None = None) -> str | None:
74
+ """解析 apiKey / header 值;返回 None 表示未解析(环境变量缺失/命令失败)。"""
75
+ env_vars: Mapping[str, str] = env if env is not None else os.environ
76
+ if raw.startswith("$$"): # 转义:字面量 $
77
+ return raw[1:]
78
+ if raw.startswith("$!"): # 转义:字面量 !
79
+ return raw[1:]
80
+ if raw.startswith("!"): # 执行命令
81
+ # 不走 shell:凭证命令是「单条命令 + 参数」形态,`shlex` 负责引号,
82
+ # `shell=False` 从根上消掉注入面。需要管道/重定向时显式写
83
+ # `!bash -lc "…"` —— 把 shell 变成一个看得见的选择,而不是默认。
84
+ try:
85
+ argv = shlex.split(raw[1:])
86
+ except ValueError: # 引号不配对
87
+ return None
88
+ if not argv:
89
+ return None
90
+ try:
91
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=30)
92
+ except (OSError, subprocess.SubprocessError):
93
+ return None
94
+ if proc.returncode != 0:
95
+ return None
96
+ out = proc.stdout.strip()
97
+ return out or None
98
+
99
+ missing = False
100
+
101
+ def _repl(match: re.Match[str]) -> str:
102
+ nonlocal missing
103
+ name = match.group(1) or match.group(2)
104
+ value = env_vars.get(name)
105
+ if value is None:
106
+ missing = True
107
+ return ""
108
+ return value
109
+
110
+ result = _ENV_RE.sub(_repl, raw)
111
+ if missing:
112
+ return None
113
+ return result
114
+
115
+
116
+ @dataclass
117
+ class ResolvedKey:
118
+ key: str | None # None = 未找到
119
+ source: str
120
+ ok: bool
121
+
122
+ def describe(self) -> str:
123
+ if self.source == "none":
124
+ return "无需密钥(本地 provider)"
125
+ if self.ok:
126
+ return f"{self.source}({self.key[:6]}…)" if self.key else self.source
127
+ return f"缺失({self.source})"
128
+
129
+
130
+ class AuthStore:
131
+ """`~/.qi/agent/auth.json`:按 provider 存 {type:api_key, key:…},权限 0600。"""
132
+
133
+ def __init__(self, path: Path | None = None):
134
+ self.path = path or (global_home() / AUTH_FILE_NAME)
135
+
136
+ def load(self) -> dict:
137
+ if not self.path.is_file():
138
+ return {}
139
+ try:
140
+ data = json.loads(self.path.read_text(encoding="utf-8"))
141
+ return data if isinstance(data, dict) else {}
142
+ except (OSError, json.JSONDecodeError):
143
+ return {}
144
+
145
+ def get(self, provider: str) -> str | None:
146
+ entry = self.load().get(provider)
147
+ if isinstance(entry, dict) and entry.get("type") == "api_key":
148
+ return entry.get("key")
149
+ return None
150
+
151
+ def save(self, data: dict) -> None:
152
+ self.path.parent.mkdir(parents=True, exist_ok=True)
153
+ self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
154
+ with contextlib.suppress(OSError): # Windows 无 posix chmod
155
+ os.chmod(self.path, 0o600)
156
+
157
+ def set_key(self, provider: str, key: str) -> None:
158
+ data = self.load()
159
+ data[provider] = {"type": "api_key", "key": key}
160
+ self.save(data)
161
+
162
+ def remove(self, provider: str) -> bool:
163
+ data = self.load()
164
+ if provider not in data:
165
+ return False
166
+ del data[provider]
167
+ self.save(data)
168
+ return True
169
+
170
+ def providers(self) -> list[str]:
171
+ data = self.load()
172
+ return sorted(
173
+ k for k, v in data.items() if isinstance(v, dict) and v.get("type") == "api_key"
174
+ )
175
+
176
+
177
+ class AuthOverride(AuthStore):
178
+ """CLI `--api-key` 的运行期覆盖:本次运行用的密钥优先于 auth store / env / models.json。
179
+
180
+ 只覆盖 `get()`,不写盘 —— 命令行给的凭证不该静默落进 `auth.json`
181
+ (要存就显式 `qi auth login`)。`providers()` 也不报它:它不是一个"已登录的 provider"。
182
+ """
183
+
184
+ def __init__(self, key: str, base: AuthStore | None = None):
185
+ super().__init__(base.path if base is not None else None)
186
+ self._key = key
187
+
188
+ def get(self, provider: str) -> str | None: # noqa: ARG002 对所有 provider 生效
189
+ return self._key
190
+
191
+
192
+ def resolve_key(provider: str, api_key_ref: str | None = None,
193
+ store: AuthStore | None = None) -> ResolvedKey:
194
+ """按 pi 顺序解析某 provider 的密钥。
195
+
196
+ 1. auth store → 2. 约定环境变量 → 3. provider apiKey 引用。
197
+ """
198
+ store = store or AuthStore()
199
+ stored = store.get(provider)
200
+ if stored:
201
+ return ResolvedKey(key=stored, source="auth", ok=True)
202
+ default_env = DEFAULT_API_KEY_ENV.get(provider)
203
+ if default_env:
204
+ value = os.environ.get(default_env)
205
+ if value:
206
+ return ResolvedKey(key=value, source=f"env:{default_env}", ok=True)
207
+ if api_key_ref:
208
+ value = resolve_value(api_key_ref)
209
+ if value:
210
+ return ResolvedKey(key=value, source=f"config:{api_key_ref}", ok=True)
211
+ return ResolvedKey(key=None, source=f"config:{api_key_ref}(未解析)", ok=False)
212
+ if default_env:
213
+ return ResolvedKey(key=None, source=f"env:{default_env}(未设置)", ok=False)
214
+ if provider in KEYLESS_PROVIDERS:
215
+ return ResolvedKey(key=None, source="none", ok=True)
216
+ return ResolvedKey(key=None, source="无密钥来源", ok=False)