@nsyan/db 1.2.0 → 1.3.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.
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  - **10 种数据库 · 6 家族**:PostgreSQL · MySQL · Oracle · 达梦 · Redis · Elasticsearch · MongoDB · Neo4j · Hive · Spark
17
17
  - **AI 工具**:查询 / 表结构 / 连接清单 / 扫描建连,读多写少场景的 token 友好输出
18
- - **扫描建连**:从 Spring / docker-compose / .env 自动抽取连接候选,终端确认后写入
18
+ - **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url 必须能被方言解析)
19
19
  - **安全模型**:只读模式、写确认 + 执行理由、连接级强制只读、家族白名单、管理命令恒拒
20
20
  - **审计**(可选):写操作按天落盘,含理由与完整 SQL
21
21
  - **零构建**:TypeScript 由 pi/tsx 直接加载,core/dialects 零 pi 运行时依赖
@@ -31,7 +31,7 @@ pi install ./packages/db # 本仓库开发者
31
31
 
32
32
  1. `/db add` 粘贴连接串一键建连(支持 `postgresql://` · `jdbc:*` · `redis://` · `mongodb+srv://` · `bolt://`/`neo4j://` · `http://host:9200`)
33
33
  2. 直接让 AI 查询:*「查一下订单表最近 10 条」*
34
- 3. `/db scan` 从项目源码自动发现数据库连接(密码只在终端补录,不进模型上下文)
34
+ 3. 扫描建连:在会话说「连一下这个项目的数据库」或「/db scan 配置pg」——AI 读取项目配置文件(任意语言)提取连接,会话内弹框确认后建连;被拒候选按原因修正重提
35
35
 
36
36
  ## 🗄️ 支持的数据库
37
37
 
@@ -73,7 +73,7 @@ pi install ./packages/db # 本仓库开发者
73
73
  | 连接级强制只读 | 标记的连接(如生产库)无视全局开关,永远只读 |
74
74
  | 家族白名单 | Redis/ES/Mongo 只读白名单;`DROP`、管理 DDL、服务端 JS、`CALL dbms.*` 恒拒 |
75
75
  | 审计日志 | 默认关;`/db config` 开启后写操作按天落盘(完整 SQL,0600) |
76
- | 凭据 | 配置文件 `0600`;扫描场景密码掩码,不进模型上下文 |
76
+ | 凭据 | 配置文件 `0600`;扫描场景密码经确认框补录(v1.3 起随 AI 阅读进会话,用户知情接受) |
77
77
 
78
78
  ## ⚙️ 设置
79
79
 
package/docs/USAGE.md CHANGED
@@ -46,18 +46,46 @@
46
46
  - `强制只读` 的连接**无视全局只读开关**,永远只接受查询;AI 侧写入会被拒并提示原因
47
47
  - 典型用法:全局允许写(开发库随便改),生产库连接强制只读
48
48
 
49
- ## 🔎 扫描建连(/db scan)
49
+ ## 🔎 扫描建连(会话 AI 驱动,v1.3.0 重构)
50
50
 
51
- 从项目源码自动抽取连接信息(Spring `application*.yml/properties`、`docker-compose.yml`、`.env`、通用 URL 正则):
51
+ 扫描建连已从"正则匹配"重构为"**会话 AI 提取**":插件不再用文件名模式/键名正则猜测配置(v1.2 及以前的 Spring 键映射、baomidou dynamic-datasource 解析、占位符 `.env` 回退等提取代码已删除),改为把项目文件树交给会话模型,由 AI 自行定位并阅读配置文件——**语言无关**(Java/Python/Go/TS/Rust 的 yml/toml/env/ini/json/硬编码均可识别),`${POSTGRES-IP:10.2.12.50}` 这类占位符、Nacos 导出、K8s manifest 等长尾格式天然理解。
52
52
 
53
- ```bash
54
- /db scan # 扫描当前工作目录
55
- /db scan ./backend # 扫描指定子目录(越界拒绝)
53
+ **用法**(在会话对话框直接说):
54
+
55
+ ```
56
+ 连一下这个项目的数据库 # 全类型扫描
57
+ /db scan 配置pg # 只提取 PostgreSQL(类型词中英文均可)
58
+ /db scan 只看 redis 和 neo4j # 多类型
56
59
  ```
57
60
 
58
- - 结果状态:✅ 可直接建 / ✏️ 待补字段 / 🔒 jasypt 加密(只标注不建)/ ⏭️ 同名已存在
59
- - **绝不静默建连、绝不静默覆盖**:逐个确认后才写入;占位符 `${KEY:default}` 取 default,`${KEY}` 依次查同目录 `.env` → 进程环境变量
60
- - AI 侧说「连一下这个项目的数据库」会调用 `scan_project_configs` 展示**掩码后**候选,建连仍需终端确认
61
+ **流程**:
62
+
63
+ 1. AI 调用 `scan_project_configs` 获取项目文件树(path 强制限定当前工作目录子树内,越界拒绝)
64
+ 2. AI 从树中挑出可能含连接配置的文件,用自带读文件工具阅读,提取候选(类型/host/端口/库名/账号/密码/连接串)
65
+ 3. AI 调用 `db_scan_save` 提交候选——工具逐个弹确认框:确认 → 补录缺字段 → **测试连接通过才写盘**;同名连接三选一(覆盖/改名/跳过)
66
+ 4. 被拒绝的候选(url 与类型矛盾等)AI 会按返回原因修正后重提
67
+
68
+ **防幻觉校验**(确定性规则,AI 不可绕过):dialectId 必须在支持列表内;带 `url` 的候选必须能被对应方言 `parseUrl` 解析(解析成功后以 URL 为准);host 必填、port 范围 1~65535。
69
+
70
+ **结果状态**:✅ 可直接建 / ✏️ 待补字段(确认框中补录)/ ⏭️ 同名已存在。
71
+
72
+ ### 配置中心场景(Nacos / Apollo / Spring Cloud Config)
73
+
74
+ 当连接配置不在本地文件、而在配置中心时(特征:`bootstrap.yml` 含 `spring.cloud.nacos.config` 或类似的配置中心指向),AI 会自动走"两跳"提取:
75
+
76
+ 1. 从 `bootstrap.yml` 读取配置中心地址与凭据(server-addr / username / password / namespace / group)
77
+ 2. 用会话自带的命令工具调 Open API 拉取配置原文——Nacos 为例:
78
+ ```bash
79
+ # 登录拿 accessToken
80
+ curl -X POST "http://<server>/nacos/v1/auth/login" -d "username=<u>&password=<p>"
81
+ # 拉取配置(tenant = namespace 的 UUID,group/file-extension 与 bootstrap 对应)
82
+ curl "http://<server>/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=dev&tenant=<namespace>&accessToken=<token>"
83
+ ```
84
+ 3. 从返回的 YAML 原文提取连接候选 → `db_scan_save` 提交(校验/确认/测试连接照常)
85
+
86
+ 本地 profile 文件(如 `application-dev.yml`)与配置中心共有的项目,两边都会被提取,重复的连接靠同名查重(⏭️ exists 状态)与确认框兜底。
87
+
88
+ **隐私行为变化**(v1.3.0,用户知情接受):配置文件原文(含密码)会随 AI 阅读进入会话上下文;v1.2 及以前"密码只在终端补录、不进模型上下文"的承诺不再适用于扫描场景(查询/连接管理不受影响)。
61
89
 
62
90
  ## 🧾 各家族 sql 形态详解
63
91
 
@@ -122,7 +150,6 @@ SHOW INDEXES
122
150
  - **结果拍平**:Node 渲染为 `:Label {属性}`,Relationship 为 `-(TYPE)-> {属性}`,Path 为 `<path:n>`;无返回记录的写语句回显变更计数(创建节点 n,设置属性 m)
123
151
  - **limit 封顶**:客户端截断对齐关系型方言(不做 Cypher LIMIT 注入,任意语句尾部加 LIMIT 不总合法)
124
152
  - **连接串**:`bolt://`/`neo4j://`/`bolt+s://`/`neo4j+s://` 等;建连统一走 Bolt 直连(`bolt://`/`bolt+s://`)——单机社区版无路由服务,`neo4j://` 路由 scheme 会报 No routing servers available;URL 路径段 = 图数据库名(缺省 `neo4j`)
125
- - **扫描建连**:Spring `spring.neo4j.uri`(Boot 3)/ `spring.data.neo4j.uri`(Boot 2)+ authentication 账号密码键、`.env` `NEO4J_URI`/`NEO4J_URL`/`BOLT_URL`、docker-compose `neo4j` 镜像(`NEO4J_AUTH`/`NEO4J_PASSWORD`)、通用 URL 正则
126
153
 
127
154
  ## ✍️ 写操作:理由与审计
128
155
 
package/index.ts CHANGED
@@ -4,7 +4,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { registry } from "./src/dialects/index.js";
5
5
  import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
6
6
  import { appendAuditLog } from "./src/core/audit.js";
7
- import { scanProject } from "./src/core/scan/candidates.js";
7
+ import { collectTree } from "./src/core/scan/tree.js";
8
+ import { validateCandidates } from "./src/core/scan/validate.js";
8
9
  import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
9
10
  import {
10
11
  loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
@@ -68,8 +69,12 @@ function familyHint(c: ConnConfig): string {
68
69
  // 系统提示触发词文案(Spec §8.5:AI 侧入口;建连写盘前必须经用户确认)
69
70
  const SCAN_TRIGGER_HINT = [
70
71
  "[项目扫描建连]",
71
- "当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”等时,调用 scan_project_configs 工具扫描项目连接候选(返回掩码结果,不写盘)。",
72
- "建连写盘前必须经用户在终端确认(/db scan),密码类字段只在终端 TUI 补录,不进模型上下文。",
72
+ "当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”“/db scan”等时,走两步 AI 扫描流程:",
73
+ " 1 步:调用 scan_project_configs 获取项目文件树(path 必须在当前工作目录子树内,越界拒绝);",
74
+ "第 2 步:从树中自行判断哪些文件可能包含数据库连接配置(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等都算),用自带读文件工具阅读它们,",
75
+ " 从中提取连接候选(类型/host/端口/库名/账号/密码),用户说“只要 pg”这类话时只提取对应类型;",
76
+ "第 3 步:调用 db_scan_save 提交候选——工具会弹确认框,用户逐个确认并补录密码后才写盘,绝不静默建连。",
77
+ "注意:配置文件中的密码会随文件内容进入会话上下文(用户已接受);候选提交后由工具校验(url 必须能被方言解析,否则被拒),被拒时根据原因修正后重提。",
73
78
  ].join("\n");
74
79
 
75
80
  function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
@@ -113,22 +118,15 @@ function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
113
118
  ].join("\n");
114
119
  }
115
120
 
116
- // scan 候选状态图标(Spec §8.5:✅ 可直接建 / ✏️ 待补字段 / 🔒 加密密码 / ⏭️ 已存在)
121
+ // scan 候选状态图标(✅ 可直接建 / ✏️ 待补字段 / ⏭️ 同名已存在)
117
122
  const SCAN_GLYPH: Record<Candidate["status"], string> = {
118
- ready: "✅", incomplete: "✏️", encrypted: "🔒", exists: "⏭️",
123
+ ready: "✅", incomplete: "✏️", exists: "⏭️",
119
124
  };
120
125
 
121
- // 密码掩码(输出层红线):真实密码不进模型上下文;jasypt ENC( 密文保留(非明文,可用信号)
122
- function maskCandidate(c: Candidate): Candidate {
123
- const p = { ...c.partial };
124
- if (typeof p.password === "string" && p.password && !p.password.startsWith("ENC(")) p.password = "***";
125
- return { ...c, partial: p };
126
- }
127
-
128
126
  function candidateLine(c: Candidate): string {
129
127
  const p = c.partial;
130
128
  const miss = c.missing.length ? `,缺: ${c.missing.join("/")}` : "";
131
- return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} <- ${c.source.file}(置信度 ${c.source.confidence})`;
129
+ return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} <- ${c.source}`;
132
130
  }
133
131
 
134
132
  // ── 导出扩展 ──────────────────────────────────────
@@ -136,88 +134,63 @@ function candidateLine(c: Candidate): string {
136
134
  // scan 向导核心(由 registerCommand 内的 scanWizard 调用;需要 ctx.ui)
137
135
 
138
136
  export default function (pi: ExtensionAPI) {
139
- // ── 代码扫描建连向导(/db scan,Spec §8.5)──────
140
- // 硬性原则:绝不静默建连(每次写盘前确认);绝不静默覆盖(同名三选一);
141
- // jasypt ENC 只标注不建;密码类字段只在此 TUI 通道补录。
142
- const scanWizard = async (ctx: any, scanPath: string) => {
143
- let candidates: Candidate[];
144
- try {
145
- candidates = await scanProject(scanPath);
146
- } catch (err) {
147
- ctx.ui.notify(`扫描失败: ${err instanceof Error ? err.message : String(err)}`, "error");
148
- return;
149
- }
150
- if (candidates.length === 0) {
151
- ctx.ui.notify(`在 ${scanPath} 未扫出数据库连接候选`, "info");
152
- return;
153
- }
154
-
155
- // 1. 分组展示
156
- ctx.ui.notify(
157
- `扫描到 ${candidates.length} 个连接候选:\n${candidates.map(candidateLine).join("\n")}\n\n接下来逐个确认,不会静默建连。`,
158
- "info",
137
+ // ── AI 候选落地(v1.3.0:scan_project_configs 返回文件树 → 会话模型提取 → db_scan_save 提交)───
138
+ // 硬性原则不变:绝不静默建连(每个候选写盘前 confirm);绝不静默覆盖(同名三选一);
139
+ // 缺字段在确认框交互中补录;testConnection 通过才保存。
140
+ const confirmAndSaveCandidate = async (ctx: any, cand: Candidate): Promise<"saved" | "skipped" | "failed"> => {
141
+ const p = cand.partial;
142
+ let name = p.name!;
143
+ const ok = await ctx.ui.confirm(
144
+ "AI 扫描建连",
145
+ `添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}`,
159
146
  );
160
-
161
- // 2. 逐个确认
162
- for (const cand of candidates) {
163
- if (cand.status === "encrypted") {
164
- ctx.ui.notify(
165
- `🔒 ${cand.partial.name}: 检测到 jasypt 加密密码(${cand.source.file}),不自动建连。请人工解密后用 /db add 手动添加。`,
166
- "info",
167
- );
168
- continue;
169
- }
170
- const ok = await ctx.ui.confirm("扫描建连", `添加 ${cand.partial.name} (${cand.dialectId})?\n来源: ${cand.source.file}`);
171
- if (!ok) continue;
172
-
173
- let name = cand.partial.name!;
174
- if (loadConfigs().some((x) => x.name === name)) {
175
- const how = await ctx.ui.select(`同名配置已存在: ${name}`, ["覆盖", "改名", "跳过"]);
176
- if (!how || how === "跳过") continue;
177
- if (how === "改名") {
178
- name = (await ctx.ui.input("新名称", name))?.trim() || name;
179
- // 改名后仍需查重:新名若也撞车,退回三选一(绝不静默覆盖)
180
- while (loadConfigs().some((x) => x.name === name)) {
181
- const again = await ctx.ui.select(`新名称 ${name} 也已存在`, ["覆盖", "再改一次", "跳过"]);
182
- if (!again || again === "跳过") { name = ""; break; }
183
- if (again === "再改一次") {
184
- name = (await ctx.ui.input("新名称", name))?.trim() || name;
185
- continue;
186
- }
187
- break; // 覆盖
147
+ if (!ok) return "skipped";
148
+
149
+ if (loadConfigs().some((x) => x.name === name)) {
150
+ const how = await ctx.ui.select(`同名配置已存在: ${name}`, ["覆盖", "改名", "跳过"]);
151
+ if (!how || how === "跳过") return "skipped";
152
+ if (how === "改名") {
153
+ name = (await ctx.ui.input("新名称", name))?.trim() || name;
154
+ while (loadConfigs().some((x) => x.name === name)) {
155
+ const again = await ctx.ui.select(`新名称 ${name} 也已存在`, ["覆盖", "再改一次", "跳过"]);
156
+ if (!again || again === "跳过") { name = ""; break; }
157
+ if (again === "再改一次") {
158
+ name = (await ctx.ui.input("新名称", name))?.trim() || name;
159
+ continue;
188
160
  }
189
- if (name === "") continue;
161
+ break; // 覆盖
190
162
  }
163
+ if (name === "") return "skipped";
191
164
  }
165
+ }
192
166
 
193
- // 缺字段追问(仅 TUI;密码在此补录,不进模型上下文)
194
- const bag: Partial<ConnConfig> = { ...cand.partial, name };
195
- for (const f of cand.missing) {
196
- const v = (await ctx.ui.input(`补充 ${f}(${name})`, ""))?.trim();
197
- if (v !== undefined && v !== "") {
198
- (bag as Record<string, unknown>)[f] = f === "port" || f === "dbIndex" ? parseInt(v, 10) : v;
199
- }
167
+ // 缺字段追问(TUI 通道补录)
168
+ const bag: Partial<ConnConfig> = { ...p, name };
169
+ for (const f of cand.missing) {
170
+ const v = (await ctx.ui.input(`补充 ${f}(${name})`, ""))?.trim();
171
+ if (v !== undefined && v !== "") {
172
+ (bag as Record<string, unknown>)[f] = f === "port" || f === "dbIndex" ? parseInt(v, 10) : v;
200
173
  }
174
+ }
201
175
 
202
- const dialect = registry.get(cand.dialectId);
203
- if (!dialect) continue;
204
- const conn: ConnConfig = { id: randomUUID(), createdAt: new Date().toISOString(), type: cand.dialectId, ...bag } as ConnConfig;
176
+ const dialect = registry.get(cand.dialectId);
177
+ if (!dialect) return "failed";
178
+ const conn: ConnConfig = { id: randomUUID(), createdAt: new Date().toISOString(), type: cand.dialectId, ...bag } as ConnConfig;
205
179
 
206
- // 3. 逐个 testConnection → 成功保存,失败给可操作建议
207
- ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
208
- const result = await dialect.testConnection(toRuntimeConfig(conn, dialect.defaultPort));
209
- if (!result.success) {
210
- ctx.ui.notify(`连接失败: ${result.error}\n未保存。请检查网络/账号后重跑 /db scan,或用 /db add 手动添加。`, "error");
211
- continue;
212
- }
213
- ctx.ui.notify(`连接成功 (${result.version}${result.warning ? ",警告: " + result.warning : ""}, ${result.latency})`, "success");
214
- const all = loadConfigs();
215
- const idx = all.findIndex((x) => x.name === name);
216
- if (idx >= 0) all[idx] = conn; else all.push(conn);
217
- saveConfigs(all);
218
- recordTestResult(name, result);
219
- ctx.ui.notify(`配置已保存: ${name}`, "success");
180
+ ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
181
+ const result = await dialect.testConnection(toRuntimeConfig(conn, dialect.defaultPort));
182
+ if (!result.success) {
183
+ ctx.ui.notify(`连接失败: ${result.error}\n未保存。请检查网络/账号后重试,或用 /db add 手动添加。`, "error");
184
+ return "failed";
220
185
  }
186
+ ctx.ui.notify(`连接成功 (${result.version ?? ""}${result.warning ? ",警告: " + result.warning : ""}, ${result.latency})`, "success");
187
+ const all = loadConfigs();
188
+ const idx = all.findIndex((x) => x.name === name);
189
+ if (idx >= 0) all[idx] = conn; else all.push(conn);
190
+ saveConfigs(all);
191
+ recordTestResult(name, result);
192
+ ctx.ui.notify(`配置已保存: ${name}`, "success");
193
+ return "saved";
221
194
  };
222
195
 
223
196
  // ── 添加数据库连接(P0:一键连接串 / 家族分支逐步表单,Spec §7/§11.1)───
@@ -754,7 +727,7 @@ export default function (pi: ExtensionAPI) {
754
727
  promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;写操作必须在 reason 参数说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),否则会被拒绝。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
755
728
  parameters: Type.Object({
756
729
  database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
757
- sql: Type.String({ description: "语句;关系型填 SQL,MongoDB 填 JSON 命令信封,Redis 填命令,ES 填 DSL,Neo4j 填 Cypher" }),,
730
+ sql: Type.String({ description: "语句;关系型填 SQL,MongoDB 填 JSON 命令信封,Redis 填命令,ES 填 DSL,Neo4j 填 Cypher" }),
758
731
  reason: Type.Optional(Type.String({ description: "执行理由,写操作必填:动机+影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\")。读操作无需填写" })),
759
732
  }),
760
733
  async execute(_toolCallId: string, params: { database?: string; sql: string; reason?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
@@ -968,28 +941,30 @@ export default function (pi: ExtensionAPI) {
968
941
  },
969
942
  });
970
943
 
971
- // 工具 4: scan_project_configs(Spec §8.5 AI 侧入口;只返回掩码候选,不写盘)
944
+ // 工具 4: scan_project_configs(v1.3.0:返回项目文件树,提取交给会话模型)
972
945
  pi.registerTool({
973
946
  name: "scan_project_configs",
974
947
  label: "扫描项目数据库配置",
975
- description: "扫描项目源码(Spring 配置 / docker-compose / .env 等)抽取数据库连接候选。只返回掩码后的候选列表,绝不写盘;建连请让用户在终端执行 /db scan 完成。",
976
- promptSnippet: "当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”等时调用。返回掩码候选与状态(可直接建/待补/加密/已存在);把结果展示给用户后,引导其在终端用 /db scan 完成建连。path 必须在当前工作目录子树内。",
948
+ description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。",
949
+ promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path 必须在当前工作目录子树内。",
977
950
  parameters: Type.Object({
978
951
  path: Type.Optional(Type.String({ description: "扫描根目录,缺省为当前工作目录;强制限定在当前工作目录子树内,越界拒绝" })),
979
952
  }),
980
953
  async execute(_toolCallId: string, params: { path?: string }, _signal: any) {
981
954
  try {
982
- const candidates = (await scanProject(params.path ?? ".")).map(maskCandidate);
983
- if (candidates.length === 0) {
984
- return {
985
- content: [{ type: "text" as const, text: `在 ${params.path ?? "当前目录"} 未扫出数据库连接候选。可建议用户用 /db add 手动添加。` }],
986
- };
987
- }
955
+ const tree = collectTree(params.path ?? ".");
988
956
  const text = [
989
- `扫描到 ${candidates.length} 个连接候选(密码已掩码,不写盘):`,
990
- ...candidates.map(candidateLine),
957
+ `项目根目录: ${tree.root}(文件总数 ${tree.total}${tree.truncated ? ",已截断展示前 " + tree.lines.length + " 条" : ""})`,
958
+ "",
959
+ "文件树(相对路径,浅层优先):",
960
+ ...tree.lines,
991
961
  "",
992
- "建连写盘需用户确认:请引导用户在终端执行 /db scan 完成逐个确认与密码补录。",
962
+ "下一步(必须执行):",
963
+ "1. 从文件树中判断哪些文件可能包含数据库连接配置——任意语言/格式都算:Spring application*.yml、Python settings/config.py、Go config.yaml、Node .env、Rust .env+config.toml、docker-compose、K8s manifest 等;",
964
+ "2. 用你的读文件工具阅读这些文件;若发现 bootstrap.yml/application.yml 指向配置中心(nacos/apollo/spring-cloud-config),用配置中心地址与凭据拉取远端配置后再提取——例如 Nacos:先用 POST {server}/nacos/v1/auth/login(form: username/password)拿 accessToken,再 GET {server}/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=<group>&tenant=<namespace-id>&accessToken=<token>,返回的 YAML 原文同样提取候选;",
965
+ "3. 从本地/远端配置中提取连接候选:dialectId(postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark)、host、port、database、username、password、url(如有完整连接串)、source(来源文件或 dataId);用户指定了类型(如「配置pg」)就只提取该类型;",
966
+ "4. 调用 db_scan_save 提交候选数组。带 url 的候选必须能被对应方言解析,编造的 url 会被拒绝;",
967
+ "5. 工具会弹确认框让用户逐个确认并补录密码,把工具返回的保存结果汇报给用户。",
993
968
  ].join("\n");
994
969
  return { content: [{ type: "text" as const, text }] };
995
970
  } catch (err) {
@@ -1000,6 +975,54 @@ export default function (pi: ExtensionAPI) {
1000
975
  },
1001
976
  });
1002
977
 
978
+ // 工具 4b: db_scan_save(v1.3.0:AI 提交提取的候选,弹框确认后写盘——会话内闭环)
979
+ pi.registerTool({
980
+ name: "db_scan_save",
981
+ label: "保存扫描到的数据库连接",
982
+ description: "接收 AI 从项目配置提取的数据库连接候选数组,逐个弹确认框(用户确认+补录缺字段+测试连接)后写盘。绝不静默建连;url 与类型矛盾的候选会被拒绝(防幻觉)。",
983
+ promptSnippet: "在 scan_project_configs 之后调用。candidates 传候选数组,每项含 dialectId/host/port/database/username/password/url/source;被拒绝的候选根据返回的 rejected 原因修正后可重提;把每个候选的保存结果(已保存/已跳过/失败)汇报给用户。",
984
+ parameters: Type.Object({
985
+ candidates: Type.Array(Type.Object({
986
+ dialectId: Type.String({ description: "数据库类型:postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark" }),
987
+ host: Type.Optional(Type.String({ description: "主机(无 url 时必填)" })),
988
+ port: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: "端口" })),
989
+ database: Type.Optional(Type.String({ description: "库名/schema(按类型语义)" })),
990
+ username: Type.Optional(Type.String({ description: "账号" })),
991
+ password: Type.Optional(Type.String({ description: "密码(缺失时确认框会引导用户补录)" })),
992
+ url: Type.Optional(Type.String({ description: "完整连接串(可选,提供时必须能被方言解析,否则整条拒绝)" })),
993
+ name: Type.Optional(Type.String({ description: "连接名称(缺省自动生成)" })),
994
+ source: Type.Optional(Type.String({ description: "来源描述,如配置文件相对路径" })),
995
+ }), { description: "连接候选数组" }),
996
+ }),
997
+ async execute(_toolCallId: string, params: { candidates: unknown[] }, _signal: any, _onUpdate?: any, ctx?: any) {
998
+ if (!ctx?.ui) {
999
+ return { content: [{ type: "text" as const, text: "db_scan_save 需要交互式会话(ctx.ui 不可用)。请引导用户在终端执行 /db add 手动添加。" }] };
1000
+ }
1001
+ const { candidates, rejected } = validateCandidates(params.candidates, new Set(loadConfigs().map((c) => c.name)));
1002
+ const lines: string[] = [];
1003
+ if (rejected.length > 0) {
1004
+ lines.push(`⛔ ${rejected.length} 个候选被拒绝(疑似编造或字段矛盾,请修正后重提):`, ...rejected.map((r) => " - " + r), "");
1005
+ }
1006
+ if (candidates.length === 0) {
1007
+ lines.push(rejected.length > 0 ? "没有可保存的候选。" : "未收到有效候选(candidates 需为数组,每项含 dialectId+host)。",
1008
+ "提示:从配置文件原文提取后重试;确认方言取值在支持列表内。");
1009
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
1010
+ }
1011
+ lines.push(`收到 ${candidates.length} 个连接候选,逐个确认:`, ...candidates.map(candidateLine), "");
1012
+ const results: string[] = [];
1013
+ for (const cand of candidates) {
1014
+ try {
1015
+ const r = await confirmAndSaveCandidate(ctx, cand);
1016
+ results.push(`${SCAN_GLYPH[cand.status]} ${cand.partial.name} (${cand.dialectId}): ${r === "saved" ? "已保存" : r === "skipped" ? "用户跳过" : "连接失败未保存"}`);
1017
+ } catch (err) {
1018
+ results.push(`❌ ${cand.partial.name} (${cand.dialectId}): 异常 ${err instanceof Error ? err.message : String(err)}`);
1019
+ }
1020
+ }
1021
+ lines.push("", "保存结果:", ...results.map((r) => " - " + r));
1022
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
1023
+ },
1024
+ });
1025
+
1003
1026
  // 工具 5: db_connections(v1.1 UX 共识 Q5:AI 自查连接清单与环境标签,识别生产库)
1004
1027
  pi.registerTool({
1005
1028
  name: "db_connections",
@@ -1043,7 +1066,7 @@ export default function (pi: ExtensionAPI) {
1043
1066
  "⚡ 切换默认",
1044
1067
  "✏️ 编辑连接",
1045
1068
  "➕ 新增连接",
1046
- "🔎 扫描建连",
1069
+ "🔎 扫描建连(会话 AI)",
1047
1070
  "🗑️ 删除连接",
1048
1071
  "⚙️ 设置",
1049
1072
  ];
@@ -1076,8 +1099,8 @@ export default function (pi: ExtensionAPI) {
1076
1099
  if (config) await editDbConfig(ctx, config);
1077
1100
  } else if (navChoice === "➕ 新增连接") {
1078
1101
  await addDbConfig(ctx);
1079
- } else if (navChoice === "🔎 扫描建连") {
1080
- await scanWizard(ctx, ".");
1102
+ } else if (navChoice === "🔎 扫描建连(会话 AI)") {
1103
+ ctx.ui.notify("扫描建连已升级为会话 AI 驱动:请在对话框直接说「连一下这个项目的数据库」或「/db scan 配置pg」,\nAI 会读取项目配置文件并提取连接,在会话内弹框确认后建连。", "info");
1081
1104
  } else if (navChoice === "🗑️ 删除连接") {
1082
1105
  await deleteDbConfig(ctx, configs);
1083
1106
  }
@@ -1090,10 +1113,9 @@ export default function (pi: ExtensionAPI) {
1090
1113
  if (config) await editDbConfig(ctx, config);
1091
1114
  } else if (sub === "rm" || sub === "remove" || sub === "del" || sub === "delete" || sub === "d") {
1092
1115
  await deleteDbConfig(ctx, configs);
1093
- } else if (sub === "scan" || sub.startsWith("scan ")) {
1094
- // /db scan [path]:path 缺省为当前工作目录,越界由 scanProject 拒绝
1095
- const scanPath = args.trim().slice(4).trim() || ".";
1096
- await scanWizard(ctx, scanPath);
1116
+ } else if (sub === "scan") {
1117
+ // /db scan [自然语言]:提取已迁移到会话 AI(正则引擎已废弃),终端仅引导
1118
+ ctx.ui.notify("扫描建连已升级为会话 AI 驱动:请在对话框直接说「连一下这个项目的数据库」或「/db scan 配置pg」。\nAI 会读取项目配置文件(任意语言)提取连接,在会话内弹框确认后建连。", "info");
1097
1119
  } else if (sub === "ls" || sub === "list") {
1098
1120
  showDbList(ctx, configs);
1099
1121
  } else {
@@ -1103,7 +1125,7 @@ export default function (pi: ExtensionAPI) {
1103
1125
  " edit 编辑连接\n" +
1104
1126
  " rm 删除连接\n" +
1105
1127
  " ls 列出所有连接\n" +
1106
- " scan 扫描项目配置建连(可带路径,缺省当前目录)\n" +
1128
+ " scan 扫描已迁移到会话 AI:在对话框说「连一下这个项目的数据库」\n" +
1107
1129
  " config 查看/修改插件设置\n" +
1108
1130
  " 默认 打开管理菜单(查看/编辑/新增/扫描/删除/设置)",
1109
1131
  "info"
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@nsyan/db",
3
- "version": "1.2.0",
4
- "description": "AI 接入数据库扩展 —— 六大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,提供查询/表结构/扫描建连/连接清单工具给 LLM",
3
+ "version": "1.3.0",
4
+ "description": "AI 数据库接入扩展 for pi —— 达梦(DM)/PostgreSQL/MySQL/Oracle/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,六大家族方言,提供查询/表结构/扫描建连/连接清单 LLM 工具 | Database access extension for the pi coding agent: query, schema browsing, connection scanning and audit tools for PostgreSQL, MySQL, Oracle, DM (Dameng 达梦), Redis, Elasticsearch, MongoDB, Neo4j, Hive and Spark",
5
5
  "keywords": [
6
6
  "pi-extension",
7
7
  "pi-package",
8
8
  "database",
9
+ "sql",
10
+ "llm",
9
11
  "postgresql",
10
12
  "mysql",
11
13
  "oracle",
12
14
  "dameng",
13
15
  "dm",
16
+ "达梦",
14
17
  "redis",
15
18
  "elasticsearch",
16
19
  "mongodb",
@@ -21,6 +24,10 @@
21
24
  "hive",
22
25
  "spark"
23
26
  ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/nsyan/pi-plugins.git"
30
+ },
24
31
  "license": "MIT",
25
32
  "main": "index.ts",
26
33
  "pi": {
package/src/core/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // core/index.ts —— core 聚合 re-export(package.json exports "./core" 的入口)
2
2
  export type { DbTypeId, DbFamily, ConnConfig, ParsedTarget, DbConnection, ExecOpts,
3
- CandidateStatus, Candidate, TableInfo, ColumnInfo, QueryResult, ListTablesResult,
3
+ CandidateStatus, Candidate, CandidateInput, TableInfo, ColumnInfo, QueryResult, ListTablesResult,
4
4
  DescribeTableResult, TestConnectionResult } from "./types.js";
5
5
  export { stripComments, splitStatements, isWriteStatement, isDropStatement } from "./sql-text.js";
6
6
  export { decide } from "./policy.js";
@@ -0,0 +1,34 @@
1
+ // scan/tree.ts —— 项目文件树收集(AI 扫描时代的"发现层")
2
+ // 设计(v1.3.0 共识):文件发现与内容提取全部交给会话模型——本模块只做确定性的事情:
3
+ // 走完整棵目录树、排除无关目录、产出相对路径清单。语言无关(Java/Py/TS/Go/Rust...)。
4
+ // 注:不再做任何"这是不是配置文件"的猜测(原文件名模式匹配已废弃)——
5
+ // 模型看到完整树后自行判断哪些文件值得读(用其自带 read 工具),长尾格式天然覆盖。
6
+
7
+ import { resolveRoot, walk } from "./walker.js";
8
+
9
+ export interface TreeResult {
10
+ root: string; // 绝对路径
11
+ total: number; // 收集到的文件总数
12
+ truncated: boolean; // 是否超出 MAX_TREE_LINES 被截断
13
+ lines: string[]; // 相对路径清单(已排序:浅层优先)
14
+ }
15
+
16
+ const MAX_TREE_LINES = 3000;
17
+
18
+ /** 收集项目文件树(相对路径排序清单);越界由 resolveRoot 拒绝 */
19
+ export function collectTree(rootInput: string): TreeResult {
20
+ const root = resolveRoot(rootInput);
21
+ const files = walk(root);
22
+ const rels = files
23
+ .map((f) => f.slice(root.length + 1).split("\\").join("/"))
24
+ .sort((a, b) => {
25
+ const da = a.split("/").length, db = b.split("/").length;
26
+ return da !== db ? da - db : a.localeCompare(b);
27
+ });
28
+ return {
29
+ root,
30
+ total: rels.length,
31
+ truncated: rels.length > MAX_TREE_LINES,
32
+ lines: rels.slice(0, MAX_TREE_LINES),
33
+ };
34
+ }
@@ -0,0 +1,117 @@
1
+ // scan/validate.ts —— AI 提交候选的校验层(防幻觉 + 归一化,纯函数可单测)
2
+ // 设计(v1.3.0 共识):候选由会话模型从配置文件提取后经 db_scan_save 提交。
3
+ // 模型可能编造 host/类型,这里用确定性规则过滤:
4
+ // ① dialectId 必须在 registry ② 带 url 时方言 parseUrl 必须能解析(claim 与 url 矛盾即拒)
5
+ // ③ 无 url 时 host 必填、port 范围校验 ④ REQUIRED 表判定缺字段 ⑤ 同名查重
6
+
7
+ import { basename } from "node:path";
8
+ import { registry } from "../../dialects/index.js";
9
+ import type { Candidate, CandidateInput, CandidateStatus, DbTypeId } from "../types.js";
10
+
11
+ /** 各类型建连必需字段(空串同样视为缺失)——家族语义,非正则,保留 */
12
+ const REQUIRED: Record<DbTypeId, string[]> = {
13
+ postgresql: ["host", "port", "database", "username", "password"],
14
+ mysql: ["host", "port", "database", "username", "password"],
15
+ oracle: ["host", "port", "database", "username", "password"],
16
+ dm: ["host", "port", "database", "username", "password"],
17
+ hive: ["host", "port", "database", "username"],
18
+ spark: ["host", "port", "database", "username"],
19
+ redis: ["host", "port", "password"],
20
+ elasticsearch: ["host", "port"],
21
+ neo4j: ["host", "port", "username", "password"], // 工作库可选(缺省 neo4j;社区版默认开认证)
22
+ mongodb: ["host", "port"], // 账号/工作库可选(本地无认证常见)
23
+ };
24
+
25
+ function nonEmpty(v: unknown): v is string | number {
26
+ return v !== undefined && v !== null && String(v).trim() !== "";
27
+ }
28
+
29
+ export interface ValidateResult {
30
+ candidates: Candidate[];
31
+ rejected: string[]; // 被拒候选及原因(回传给模型可自查重提)
32
+ }
33
+
34
+ /** 校验 + 归一化一批模型提交的候选 */
35
+ export function validateCandidates(raw: unknown, existingNames: Set<string>): ValidateResult {
36
+ const list = Array.isArray(raw) ? raw : [raw];
37
+ const candidates: Candidate[] = [];
38
+ const rejected: string[] = [];
39
+ const seenNames = new Set(existingNames);
40
+
41
+ for (const [i, item] of list.entries()) {
42
+ if (item === null || typeof item !== "object") {
43
+ rejected.push(`#${i + 1}: 非对象,已丢弃`);
44
+ continue;
45
+ }
46
+ const c = item as Record<string, unknown> & Partial<CandidateInput>;
47
+
48
+ // ① 类型必须被注册表认领
49
+ const dialect = registry.get(c.dialectId as DbTypeId);
50
+ if (!dialect) {
51
+ rejected.push(`#${i + 1}: 未知数据库类型 ${JSON.stringify(c.dialectId)},支持: ${[...registry.keys()].join("/")}`);
52
+ continue;
53
+ }
54
+
55
+ // ② 带 url 时:方言 parseUrl 必须能解析(防幻觉——claim 与 url 矛盾整条拒)
56
+ // 解析成功时以解析结果为准(host/port/database/username/password 由 URL 补全)
57
+ let bag: Partial<CandidateInput> = {
58
+ host: typeof c.host === "string" ? c.host.trim() : undefined,
59
+ port: typeof c.port === "number" ? c.port : parseInt(String(c.port ?? ""), 10) || undefined,
60
+ username: nonEmpty(c.username) ? String(c.username) : undefined,
61
+ password: nonEmpty(c.password) ? String(c.password) : undefined,
62
+ database: nonEmpty(c.database) ? String(c.database) : undefined,
63
+ dbIndex: typeof c.dbIndex === "number" ? c.dbIndex : parseInt(String(c.dbIndex ?? ""), 10) || undefined,
64
+ };
65
+ if (nonEmpty(c.url)) {
66
+ const parsed = dialect.parseUrl(String(c.url).trim());
67
+ if (!parsed) {
68
+ rejected.push(`#${i + 1}: url 无法被 ${dialect.id} 方言解析(疑似编造),已拒绝: ${String(c.url).slice(0, 80)}`);
69
+ continue;
70
+ }
71
+ bag = {
72
+ ...bag,
73
+ host: parsed.host,
74
+ port: parsed.port,
75
+ username: bag.username ?? parsed.username,
76
+ password: bag.password ?? parsed.password,
77
+ database: bag.database ?? parsed.database,
78
+ dbIndex: bag.dbIndex ?? parsed.dbIndex,
79
+ };
80
+ }
81
+
82
+ // ③ 无 url 时 host 必填;port 范围校验
83
+ if (!nonEmpty(bag.host)) {
84
+ rejected.push(`#${i + 1}: ${dialect.id} 候选缺 host(无 url 时 host 必填)`);
85
+ continue;
86
+ }
87
+ if (bag.port !== undefined && (!Number.isInteger(bag.port) || bag.port < 1 || bag.port > 65535)) {
88
+ rejected.push(`#${i + 1}: ${dialect.id} 候选 port 非法: ${bag.port}`);
89
+ continue;
90
+ }
91
+
92
+ // ④ 缺字段判定
93
+ const missing = (REQUIRED[dialect.id] ?? []).filter((f) => !nonEmpty(bag[f as keyof CandidateInput]));
94
+
95
+ // ⑤ 命名:显式 name > 默认;同名(含与本批前序候选撞名)→ exists 状态
96
+ let name = nonEmpty(c.name) ? String(c.name).trim()
97
+ : `${dialect.id}-${bag.host}${bag.database ? "-" + bag.database : ""}`;
98
+ let status: CandidateStatus = missing.length > 0 ? "incomplete" : "ready";
99
+ if (seenNames.has(name)) status = "exists";
100
+ seenNames.add(name);
101
+
102
+ candidates.push({
103
+ status,
104
+ dialectId: dialect.id,
105
+ partial: { name, ...bag } as Partial<import("../types.js").ConnConfig>,
106
+ missing,
107
+ source: nonEmpty(c.source) ? String(c.source) : "AI 提取",
108
+ warnings: Array.isArray(c.warnings) ? c.warnings.map(String).slice(0, 3) : undefined,
109
+ });
110
+ }
111
+ return { candidates, rejected };
112
+ }
113
+
114
+ /** 供展示层使用的默认项目名(db_scan_save 的 name 兜底) */
115
+ export function defaultProjectName(root: string): string {
116
+ return basename(root);
117
+ }