@cxxl/dsh-sqlite 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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/cordis.patch.yml +6 -0
- package/lib/engine.js +351 -0
- package/lib/index.js +58 -0
- package/lib/tools.js +168 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 happyCxxl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# dsh-sqlite
|
|
2
|
+
|
|
3
|
+
给 DeepSeek Harness 的 agent 提供持久关系型 SQL 能力的插件:跨会话、跨工作区、跨机器可同步的结构化数据存储。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
dsh plugin --profile web add @cxxl/dsh-sqlite
|
|
9
|
+
# 装完重启一次(bundle 层只在启动时读取)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 五个工具
|
|
13
|
+
|
|
14
|
+
| 工具 | 作用 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `sqlite_query` | 只读查询(SELECT / WITH / EXPLAIN / 只读 PRAGMA),结果 Markdown 表格,默认 100 行、上限 500 |
|
|
17
|
+
| `sqlite_exec` | 写操作(标准 DDL/DML,可多语句);危险操作需 `confirm: true` |
|
|
18
|
+
| `sqlite_tables` | 列库文件、表结构、行数,附 quick_check 完整性检查 |
|
|
19
|
+
| `sqlite_export` | 导出为 SQL 文本(全部表或指定表),用于备份 / git 同步 |
|
|
20
|
+
| `sqlite_import` | 从 SQL 文本恢复(覆盖式:替换文件中包含的表,其余保留) |
|
|
21
|
+
|
|
22
|
+
## 数据位置
|
|
23
|
+
|
|
24
|
+
- 默认库:`~/.dsh/data/agent.db`;命名库:`~/.dsh/data/<库名>.db`(库名仅允许 `[a-zA-Z0-9_-]{1,64}`)
|
|
25
|
+
- WAL 模式 + busy_timeout 3000ms,多会话并发安全;首次调用才打开,插件停用自动关闭
|
|
26
|
+
|
|
27
|
+
## 安全边界
|
|
28
|
+
|
|
29
|
+
- `sqlite_exec` 拒绝:ATTACH / DETACH / LOAD_EXTENSION / 全部 PRAGMA
|
|
30
|
+
- 危险操作必须显式 `confirm: true`:DROP、无 WHERE 的 DELETE/UPDATE、含 DROP 的 ALTER、VACUUM/REINDEX
|
|
31
|
+
- 单次 SQL ≤ 64KB;大批量写入请分批(每批约 1000 行)
|
|
32
|
+
|
|
33
|
+
## 跨机器同步
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
机器A:sqlite_export(to: "<仓库>/data/agent.sql") → git commit & push
|
|
37
|
+
机器B:git pull → sqlite_import(from: "<仓库>/data/agent.sql")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 已知限制(v1)
|
|
41
|
+
|
|
42
|
+
- SQL 字符串值内不要包含分号(语句按分号切分)
|
|
43
|
+
- 大表查询请用 WHERE/LIMIT 收窄;查询结果最多返回 500 行
|
|
44
|
+
- SQLite 动态类型:列声明类型是"亲和性建议",值与类型不符时以存储为准
|
|
45
|
+
- `to` / `from` 建议使用绝对路径
|
|
46
|
+
- 部分导出时注意表间外键引用关系
|
|
47
|
+
|
|
48
|
+
## 引擎
|
|
49
|
+
|
|
50
|
+
Node 内置 `node:sqlite`(实验性,Node ≥ 22.5),零原生依赖;所有引擎调用隔离在 `lib/engine.js`,可整体替换为 better-sqlite3 而不动其余代码。
|
package/cordis.patch.yml
ADDED
package/lib/engine.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// engine.js — 所有 node:sqlite / 文件 IO 调用隔离于此。
|
|
2
|
+
// 实验性 API 集中在这一层:将来替换 better-sqlite3 只改本文件。
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join, resolve, sep, dirname } from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
mkdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
|
|
15
|
+
export const MAX_SQL_LEN = 64 * 1024
|
|
16
|
+
export const MAX_ROWS = 500
|
|
17
|
+
export const MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
|
18
|
+
export const DEFAULT_DB = 'default'
|
|
19
|
+
const DB_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
|
20
|
+
|
|
21
|
+
// 只读 PRAGMA 白名单(query 通道唯一放行的 PRAGMA)
|
|
22
|
+
const READONLY_PRAGMAS = new Set([
|
|
23
|
+
'table_info',
|
|
24
|
+
'table_list',
|
|
25
|
+
'index_list',
|
|
26
|
+
'index_info',
|
|
27
|
+
'foreign_key_list',
|
|
28
|
+
'quick_check',
|
|
29
|
+
'integrity_check',
|
|
30
|
+
'freelist_count',
|
|
31
|
+
'page_count',
|
|
32
|
+
'database_list',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
// exec 放行的语句类型(标准 DDL/DML)
|
|
36
|
+
const EXEC_ALLOW = new Set([
|
|
37
|
+
'CREATE', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'REPLACE',
|
|
38
|
+
'WITH', 'BEGIN', 'COMMIT', 'ROLLBACK', 'END', 'VACUUM', 'REINDEX',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
// exec 一律拒绝的语句类型
|
|
42
|
+
const EXEC_DENY = new Set(['ATTACH', 'DETACH', 'LOAD_EXTENSION', 'PRAGMA'])
|
|
43
|
+
|
|
44
|
+
const QUERY_ALLOW = new Set(['SELECT', 'WITH', 'EXPLAIN', 'PRAGMA'])
|
|
45
|
+
|
|
46
|
+
export function getDataDir() {
|
|
47
|
+
return process.env.DSH_SQLITE_DATA_DIR || join(homedir(), '.dsh', 'data')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureDataDir() {
|
|
51
|
+
const dir = getDataDir()
|
|
52
|
+
mkdirSync(dir, { recursive: true })
|
|
53
|
+
return dir
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function dumpFileName(dbName) {
|
|
57
|
+
return `${dbName === DEFAULT_DB ? 'agent' : dbName}.sql`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function dbFile(dbName) {
|
|
61
|
+
const name = dbName === undefined || dbName === null || dbName === '' ? DEFAULT_DB : String(dbName)
|
|
62
|
+
if (name !== DEFAULT_DB && !DB_NAME_RE.test(name)) {
|
|
63
|
+
throw new Error(`db 名不合法:"${name}"(仅允许字母/数字/_/-,1-64 位)`)
|
|
64
|
+
}
|
|
65
|
+
const dataDir = ensureDataDir()
|
|
66
|
+
const file = name === DEFAULT_DB ? 'agent.db' : `${name}.db`
|
|
67
|
+
const p = resolve(dataDir, file)
|
|
68
|
+
if (!p.startsWith(resolve(dataDir) + sep)) throw new Error('db 路径越界')
|
|
69
|
+
return { name, file: p }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const openDbs = new Map()
|
|
73
|
+
|
|
74
|
+
function openDb(dbName) {
|
|
75
|
+
const { name, file } = dbFile(dbName)
|
|
76
|
+
let db = openDbs.get(name)
|
|
77
|
+
if (db === undefined) {
|
|
78
|
+
db = new DatabaseSync(file)
|
|
79
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
80
|
+
db.exec('PRAGMA busy_timeout = 3000')
|
|
81
|
+
openDbs.set(name, db)
|
|
82
|
+
}
|
|
83
|
+
return { name, db }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function closeAll() {
|
|
87
|
+
for (const db of openDbs.values()) {
|
|
88
|
+
try { db.close() } catch { /* noop */ }
|
|
89
|
+
}
|
|
90
|
+
openDbs.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 分号切分语句。v1 已知限制:字符串字面量中的分号会破坏切分——
|
|
94
|
+
// 文档已提示"值内不要包含分号"。
|
|
95
|
+
export function splitStatements(sql) {
|
|
96
|
+
return String(sql)
|
|
97
|
+
.split(';')
|
|
98
|
+
.map((s) => s.trim())
|
|
99
|
+
.filter((s) => s.length > 0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stripLeadingComments(s) {
|
|
103
|
+
let out = s
|
|
104
|
+
for (;;) {
|
|
105
|
+
const t = out.trimStart()
|
|
106
|
+
if (t.startsWith('--')) {
|
|
107
|
+
const nl = t.indexOf('\n')
|
|
108
|
+
out = nl === -1 ? '' : t.slice(nl + 1)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (t.startsWith('/*')) {
|
|
112
|
+
const end = t.indexOf('*/')
|
|
113
|
+
out = end === -1 ? '' : t.slice(end + 2)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
return out.trimStart()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function firstKeyword(stmt) {
|
|
122
|
+
const m = /^([A-Za-z]+)/.exec(stripLeadingComments(stmt))
|
|
123
|
+
return m ? m[1].toUpperCase() : ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dangerOf(stmt) {
|
|
127
|
+
const kw = firstKeyword(stmt)
|
|
128
|
+
if (kw === 'DROP' || kw === 'VACUUM' || kw === 'REINDEX') return `${kw} 属危险操作`
|
|
129
|
+
if ((kw === 'DELETE' || kw === 'UPDATE') && !/\bWHERE\b/i.test(stmt)) {
|
|
130
|
+
return `${kw} 未带 WHERE,将影响整张表`
|
|
131
|
+
}
|
|
132
|
+
if (kw === 'ALTER' && /\bDROP\b/i.test(stmt)) return 'ALTER 含 DROP 属危险操作'
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertNotAborted(signal) {
|
|
137
|
+
if (signal && signal.aborted) throw new Error('执行已取消')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rowsToMarkdown(columns, rows, truncated) {
|
|
141
|
+
const head = `| ${columns.join(' | ')} |`
|
|
142
|
+
const sep = `| ${columns.map(() => '---').join(' | ')} |`
|
|
143
|
+
const body = rows.map((r) => {
|
|
144
|
+
const cells = columns.map((c) => {
|
|
145
|
+
let v = r[c]
|
|
146
|
+
if (v === null || v === undefined) return 'NULL'
|
|
147
|
+
if (v instanceof Uint8Array) return '<blob>'
|
|
148
|
+
let s = String(v).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
|
|
149
|
+
if (s.length > 200) s = s.slice(0, 200) + '…'
|
|
150
|
+
return s
|
|
151
|
+
})
|
|
152
|
+
return `| ${cells.join(' | ')} |`
|
|
153
|
+
})
|
|
154
|
+
const lines = [head, sep, ...body]
|
|
155
|
+
if (truncated) lines.push('', `(已截断:还有更多行未显示,请用 WHERE/LIMIT 收窄查询)`)
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function runQuery(sql, dbName, maxRows, signal) {
|
|
160
|
+
assertNotAborted(signal)
|
|
161
|
+
const text = String(sql ?? '')
|
|
162
|
+
if (text.length === 0) throw new Error('sql 参数为空')
|
|
163
|
+
if (text.length > MAX_SQL_LEN) throw new Error(`SQL 超过 ${MAX_SQL_LEN / 1024}KB 上限,请拆分`)
|
|
164
|
+
const limit = Math.max(1, Math.min(MAX_ROWS, Number.isFinite(maxRows) ? Math.floor(maxRows) : 100))
|
|
165
|
+
const stmts = splitStatements(text)
|
|
166
|
+
if (stmts.length === 0) throw new Error('未解析到任何语句')
|
|
167
|
+
for (const stmt of stmts) {
|
|
168
|
+
const kw = firstKeyword(stmt)
|
|
169
|
+
if (!QUERY_ALLOW.has(kw)) {
|
|
170
|
+
throw new Error(`sqlite_query 仅允许 SELECT / WITH / EXPLAIN / 只读 PRAGMA,收到以 ${kw || '?'} 开头的语句——修改数据请改用 sqlite_exec`)
|
|
171
|
+
}
|
|
172
|
+
if (kw === 'PRAGMA') {
|
|
173
|
+
const m = /^PRAGMA\s+([a-zA-Z_]+)/i.exec(stripLeadingComments(stmt))
|
|
174
|
+
if (!m || !READONLY_PRAGMAS.has(m[1].toLowerCase())) {
|
|
175
|
+
throw new Error(`PRAGMA ${m ? m[1] : '?'} 不在只读白名单内(仅允许 ${[...READONLY_PRAGMAS].join(' / ')})`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const { db } = openDb(dbName)
|
|
180
|
+
const sections = []
|
|
181
|
+
for (const stmt of stmts) {
|
|
182
|
+
assertNotAborted(signal)
|
|
183
|
+
const prepared = db.prepare(stmt)
|
|
184
|
+
const all = prepared.all()
|
|
185
|
+
let columns = []
|
|
186
|
+
try {
|
|
187
|
+
const raw = prepared.columns()
|
|
188
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
189
|
+
columns = raw.map((c) => (typeof c === 'string' ? c : c.name))
|
|
190
|
+
}
|
|
191
|
+
} catch { /* older node fallback */ }
|
|
192
|
+
if (columns.length === 0 && all.length > 0) columns = Object.keys(all[0])
|
|
193
|
+
const truncated = all.length > limit
|
|
194
|
+
const rows = truncated ? all.slice(0, limit) : all
|
|
195
|
+
sections.push(rowsToMarkdown(columns, rows, truncated))
|
|
196
|
+
}
|
|
197
|
+
return sections.join('\n\n')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function runExec(sql, dbName, confirm, signal) {
|
|
201
|
+
assertNotAborted(signal)
|
|
202
|
+
const text = String(sql ?? '')
|
|
203
|
+
if (text.length === 0) throw new Error('sql 参数为空')
|
|
204
|
+
if (text.length > MAX_SQL_LEN) throw new Error(`SQL 超过 ${MAX_SQL_LEN / 1024}KB 上限,大批量写入请分批`)
|
|
205
|
+
const stmts = splitStatements(text)
|
|
206
|
+
if (stmts.length === 0) throw new Error('未解析到任何语句')
|
|
207
|
+
const dangers = []
|
|
208
|
+
for (const stmt of stmts) {
|
|
209
|
+
const kw = firstKeyword(stmt)
|
|
210
|
+
if (EXEC_DENY.has(kw)) {
|
|
211
|
+
throw new Error(`拒绝执行:${kw} 语句被禁用(安全边界:不允许挂载外部文件 / 修改引擎配置)`)
|
|
212
|
+
}
|
|
213
|
+
if (!EXEC_ALLOW.has(kw)) {
|
|
214
|
+
throw new Error(`不支持的语句类型:${kw || '?'}(sqlite_exec 仅支持标准 DDL/DML)`)
|
|
215
|
+
}
|
|
216
|
+
const d = dangerOf(stmt)
|
|
217
|
+
if (d !== null) dangers.push(`「${stmt.slice(0, 60)}${stmt.length > 60 ? '…' : ''}」${d}`)
|
|
218
|
+
}
|
|
219
|
+
if (dangers.length > 0 && confirm !== true) {
|
|
220
|
+
throw new Error(`危险操作需确认,请显式传 confirm: true 后重试:\n${dangers.join('\n')}`)
|
|
221
|
+
}
|
|
222
|
+
const { db } = openDb(dbName)
|
|
223
|
+
assertNotAborted(signal)
|
|
224
|
+
db.exec(text)
|
|
225
|
+
return stmts.length
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listTables(dbName, signal) {
|
|
229
|
+
assertNotAborted(signal)
|
|
230
|
+
const { name, db } = openDb(dbName)
|
|
231
|
+
const tables = db.prepare(
|
|
232
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
233
|
+
).all()
|
|
234
|
+
const out = []
|
|
235
|
+
for (const t of tables) {
|
|
236
|
+
assertNotAborted(signal)
|
|
237
|
+
const info = db.prepare(`PRAGMA table_info(${quoteIdent(t.name)})`).all()
|
|
238
|
+
const columns = info.map((c) => ({
|
|
239
|
+
name: c.name,
|
|
240
|
+
type: c.type || '',
|
|
241
|
+
notnull: c.notnull === 1,
|
|
242
|
+
pk: c.pk === 1,
|
|
243
|
+
}))
|
|
244
|
+
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${quoteIdent(t.name)}`).all()
|
|
245
|
+
out.push({ name: t.name, columns, rowCount: cnt[0] ? Number(cnt[0].c) : 0 })
|
|
246
|
+
}
|
|
247
|
+
let quickCheck = 'ok'
|
|
248
|
+
try {
|
|
249
|
+
const q = db.prepare('PRAGMA quick_check').all()
|
|
250
|
+
quickCheck = q[0] && q[0].quick_check !== undefined ? String(q[0].quick_check) : 'ok'
|
|
251
|
+
} catch (err) {
|
|
252
|
+
quickCheck = `quick_check 失败:${err.message}`
|
|
253
|
+
}
|
|
254
|
+
return { dbName: name, tables: out, quickCheck }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function listDbFiles() {
|
|
258
|
+
const dataDir = ensureDataDir()
|
|
259
|
+
const entries = []
|
|
260
|
+
for (const f of readdirSync(dataDir)) {
|
|
261
|
+
if (!f.endsWith('.db')) continue
|
|
262
|
+
try {
|
|
263
|
+
const st = statSync(join(dataDir, f))
|
|
264
|
+
entries.push({ name: f, bytes: st.size })
|
|
265
|
+
} catch { /* noop */ }
|
|
266
|
+
}
|
|
267
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
268
|
+
return entries
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function allTableNames(dbName, signal) {
|
|
272
|
+
assertNotAborted(signal)
|
|
273
|
+
const { db } = openDb(dbName)
|
|
274
|
+
return db.prepare(
|
|
275
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
276
|
+
).all().map((t) => t.name)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function quoteIdent(s) {
|
|
280
|
+
return `"${String(s).replace(/"/g, '""')}"`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function quoteValue(v) {
|
|
284
|
+
if (v === null || v === undefined) return 'NULL'
|
|
285
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL'
|
|
286
|
+
if (typeof v === 'bigint') return String(v)
|
|
287
|
+
if (v instanceof Uint8Array) return `X'${Buffer.from(v).toString('hex')}'`
|
|
288
|
+
return `'${String(v).replace(/'/g, "''")}'`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function dumpSql(dbName, tables, signal) {
|
|
292
|
+
assertNotAborted(signal)
|
|
293
|
+
const { db } = openDb(dbName)
|
|
294
|
+
const existing = allTableNames(dbName, signal)
|
|
295
|
+
const selected = tables === undefined || tables === null || tables.length === 0
|
|
296
|
+
? existing
|
|
297
|
+
: [...new Set(tables.map((t) => String(t)))]
|
|
298
|
+
for (const t of selected) {
|
|
299
|
+
if (!existing.includes(t)) throw new Error(`表不存在:${t}(现有表:${existing.join(', ') || '无'})`)
|
|
300
|
+
}
|
|
301
|
+
const lines = [`-- dsh-sqlite export · db=${dbName} · tables=${selected.join(',')}`, 'BEGIN;']
|
|
302
|
+
let rowCount = 0
|
|
303
|
+
for (const t of selected) {
|
|
304
|
+
assertNotAborted(signal)
|
|
305
|
+
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(t)
|
|
306
|
+
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(t)};`)
|
|
307
|
+
lines.push(`${schema && schema.sql ? schema.sql : `CREATE TABLE ${quoteIdent(t)} (x)`};`)
|
|
308
|
+
const rows = db.prepare(`SELECT * FROM ${quoteIdent(t)}`).all()
|
|
309
|
+
const cols = rows.length > 0 ? Object.keys(rows[0]) : []
|
|
310
|
+
for (const r of rows) {
|
|
311
|
+
if (cols.length === 0) break
|
|
312
|
+
const values = cols.map((c) => quoteValue(r[c]))
|
|
313
|
+
lines.push(`INSERT OR REPLACE INTO ${quoteIdent(t)} (${cols.map(quoteIdent).join(', ')}) VALUES (${values.join(', ')});`)
|
|
314
|
+
rowCount++
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
lines.push('COMMIT;')
|
|
318
|
+
return { text: lines.join('\n'), tableCount: selected.length, rowCount }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function exportToFile(dbName, tables, to, signal) {
|
|
322
|
+
const { name } = dbFile(dbName)
|
|
323
|
+
const dump = dumpSql(dbName, tables, signal)
|
|
324
|
+
const target = to !== undefined && to !== null && String(to).trim() !== ''
|
|
325
|
+
? String(to).trim()
|
|
326
|
+
: join(ensureDataDir(), dumpFileName(name))
|
|
327
|
+
const dir = dirname(resolve(target))
|
|
328
|
+
mkdirSync(dir, { recursive: true })
|
|
329
|
+
writeFileSync(target, dump.text, 'utf8')
|
|
330
|
+
return { path: resolve(target), tables: dump.tableCount, rows: dump.rowCount }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function importFromFile(dbName, from, signal) {
|
|
334
|
+
assertNotAborted(signal)
|
|
335
|
+
const { name } = dbFile(dbName)
|
|
336
|
+
const src = from !== undefined && from !== null && String(from).trim() !== ''
|
|
337
|
+
? String(from).trim()
|
|
338
|
+
: join(getDataDir(), dumpFileName(name))
|
|
339
|
+
if (!existsSync(src)) throw new Error(`文件不存在:${src}`)
|
|
340
|
+
const st = statSync(src)
|
|
341
|
+
if (st.size > MAX_IMPORT_BYTES) {
|
|
342
|
+
throw new Error(`导入文件超过 ${MAX_IMPORT_BYTES / 1024 / 1024}MB 上限(${st.size} 字节)`)
|
|
343
|
+
}
|
|
344
|
+
const text = readFileSync(src, 'utf8')
|
|
345
|
+
const stmts = splitStatements(text)
|
|
346
|
+
if (stmts.length === 0) throw new Error(`文件内容为空或无法解析:${src}`)
|
|
347
|
+
const { db } = openDb(dbName)
|
|
348
|
+
assertNotAborted(signal)
|
|
349
|
+
db.exec(text)
|
|
350
|
+
return { path: resolve(src), statements: stmts.length, bytes: st.size }
|
|
351
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// index.js — 插件入口:注册五个工具 + 可选挂载自测。
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { toolDefs } from './tools.js'
|
|
8
|
+
import * as engine from './engine.js'
|
|
9
|
+
|
|
10
|
+
export const name = '@cxxl/dsh-sqlite'
|
|
11
|
+
export const inject = ['tools']
|
|
12
|
+
|
|
13
|
+
export function apply(ctx) {
|
|
14
|
+
for (const key of Object.keys(toolDefs)) {
|
|
15
|
+
ctx.tools.register(defineTool(toolDefs[key]))
|
|
16
|
+
}
|
|
17
|
+
ctx.effect(() => () => engine.closeAll())
|
|
18
|
+
|
|
19
|
+
// 挂载自测:DSH_PLUGIN_SELFTEST=1 时在临时数据目录跑一遍真实执行管线。
|
|
20
|
+
if (process.env.DSH_PLUGIN_SELFTEST === '1') void selfTest(ctx)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function selfTest(ctx) {
|
|
24
|
+
const prev = process.env.DSH_SQLITE_DATA_DIR
|
|
25
|
+
let tmp = null
|
|
26
|
+
try {
|
|
27
|
+
tmp = mkdtempSync(join(tmpdir(), 'dsh-sqlite-selftest-'))
|
|
28
|
+
process.env.DSH_SQLITE_DATA_DIR = tmp
|
|
29
|
+
const signal = new AbortController().signal
|
|
30
|
+
const call = (n, args) => ctx.tools.execute({ callId: CallId(`sqlite-self-${n}`), name: n, arguments: args, signal })
|
|
31
|
+
|
|
32
|
+
let r = await call('sqlite_exec', { sql: 'CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT); INSERT OR REPLACE INTO t (id, v) VALUES (1, \'hello\');' })
|
|
33
|
+
if (r.isError) throw new Error(`exec 失败: ${JSON.stringify(r.error)}`)
|
|
34
|
+
|
|
35
|
+
r = await call('sqlite_query', { sql: 'SELECT * FROM t' })
|
|
36
|
+
if (r.isError || !String(r.value).includes('hello')) throw new Error(`query 失败: ${r.isError ? JSON.stringify(r.error) : r.value}`)
|
|
37
|
+
|
|
38
|
+
r = await call('sqlite_exec', { sql: 'DROP TABLE t' })
|
|
39
|
+
if (r.isError || !String(r.value).includes('confirm')) throw new Error(`危险拦截失效: ${r.isError ? JSON.stringify(r.error) : r.value}`)
|
|
40
|
+
|
|
41
|
+
r = await call('sqlite_exec', { sql: 'DROP TABLE t', confirm: true })
|
|
42
|
+
if (r.isError) throw new Error(`confirm 执行失败: ${JSON.stringify(r.error)}`)
|
|
43
|
+
|
|
44
|
+
r = await call('sqlite_tables', {})
|
|
45
|
+
if (r.isError) throw new Error(`tables 失败: ${JSON.stringify(r.error)}`)
|
|
46
|
+
|
|
47
|
+
console.log('[dsh-sqlite] self-test PASS')
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.log(`[dsh-sqlite] self-test FAIL: ${(err && err.message) || err}`)
|
|
50
|
+
} finally {
|
|
51
|
+
if (prev === undefined) delete process.env.DSH_SQLITE_DATA_DIR
|
|
52
|
+
else process.env.DSH_SQLITE_DATA_DIR = prev
|
|
53
|
+
engine.closeAll()
|
|
54
|
+
if (tmp) {
|
|
55
|
+
try { rmSync(tmp, { recursive: true, force: true }) } catch { /* noop */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// tools.js — 五个模型工具的定义(纯对象,不依赖 DSH 包,可独立冒烟测试)。
|
|
2
|
+
// 描述即接口:写清 做什么 / 何时用 / 何时别用 / 副作用 / 防错提示。
|
|
3
|
+
// 注意:描述文本禁用双花括号(会破坏 code-mode prompt 组装)。
|
|
4
|
+
import * as engine from './engine.js'
|
|
5
|
+
|
|
6
|
+
function renderText(_args, value) {
|
|
7
|
+
return [{ type: 'text', text: value }]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function catchText(fn) {
|
|
11
|
+
return async (args, exec) => {
|
|
12
|
+
try {
|
|
13
|
+
return await fn(args, exec)
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return `错误:${(err && err.message) || String(err)}`
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const toolDefs = {
|
|
21
|
+
query: {
|
|
22
|
+
name: 'sqlite_query',
|
|
23
|
+
description:
|
|
24
|
+
'只读查询 DSH 持久 SQLite 数据库(agent 跨会话保存的结构化数据)。' +
|
|
25
|
+
'何时用:需要读取、统计、核对之前保存的数据时。' +
|
|
26
|
+
'仅支持 SELECT / WITH / EXPLAIN 及少量只读 PRAGMA(如 table_info);任何修改请改用 sqlite_exec。' +
|
|
27
|
+
'结果以 Markdown 表格返回,默认最多 100 行、硬上限 500,超出会截断并提示。' +
|
|
28
|
+
'db 参数:默认 "default"(对应 ~/.dsh/data/agent.db),其它值对应 ~/.dsh/data/<值>.db。' +
|
|
29
|
+
'注意:SQL 字符串值内不要包含分号;大表请用 WHERE/LIMIT 收窄查询。',
|
|
30
|
+
parameters: {
|
|
31
|
+
sql: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
required: true,
|
|
34
|
+
description: '只读 SQL:SELECT / WITH / EXPLAIN / 只读 PRAGMA,可用分号分隔多条。',
|
|
35
|
+
},
|
|
36
|
+
db: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: '库名,默认 "default"。命名库对应 ~/.dsh/data/<库名>.db。',
|
|
39
|
+
},
|
|
40
|
+
maxRows: {
|
|
41
|
+
type: 'number',
|
|
42
|
+
description: '返回行数上限:默认 100,最大 500。',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
output: { schema: { type: 'string' }, render: renderText },
|
|
46
|
+
execute: catchText(async (args, exec) => {
|
|
47
|
+
return engine.runQuery(args.sql, args.db, args.maxRows, exec && exec.signal)
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
exec: {
|
|
52
|
+
name: 'sqlite_exec',
|
|
53
|
+
description:
|
|
54
|
+
'执行写操作 SQL(可多条,以分号分隔)并持久化到 DSH SQLite 数据库。' +
|
|
55
|
+
'何时用:用户要求记录、跟踪、保存结构化数据,或需要建表/更新/删除时。' +
|
|
56
|
+
'支持 CREATE/INSERT/UPDATE/DELETE/DROP/ALTER/REPLACE 及 BEGIN/COMMIT/ROLLBACK;ATTACH/DETACH/LOAD_EXTENSION/PRAGMA 一律拒绝。' +
|
|
57
|
+
'危险操作必须显式传 confirm: true 才执行:DROP 语句、无 WHERE 的 DELETE/UPDATE、含 DROP 的 ALTER、VACUUM/REINDEX。' +
|
|
58
|
+
'建议写防御性 SQL:CREATE TABLE IF NOT EXISTS、INSERT OR REPLACE。' +
|
|
59
|
+
'大批量写入请分批(每批约 1000 行),单次 SQL 不超过 64KB。db 参数同 sqlite_query。',
|
|
60
|
+
parameters: {
|
|
61
|
+
sql: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
required: true,
|
|
64
|
+
description: '写操作 SQL(标准 DDL/DML),可用分号分隔多条。',
|
|
65
|
+
},
|
|
66
|
+
db: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: '库名,默认 "default"。',
|
|
69
|
+
},
|
|
70
|
+
confirm: {
|
|
71
|
+
type: 'boolean',
|
|
72
|
+
description: '危险操作(DROP / 无 WHERE 的 DELETE、UPDATE / 含 DROP 的 ALTER / VACUUM / REINDEX)必须为 true。',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
output: { schema: { type: 'string' }, render: renderText },
|
|
76
|
+
execute: catchText(async (args, exec) => {
|
|
77
|
+
const n = engine.runExec(args.sql, args.db, args.confirm, exec && exec.signal)
|
|
78
|
+
return `执行成功:${n} 条语句已写入数据库。`
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
tables: {
|
|
83
|
+
name: 'sqlite_tables',
|
|
84
|
+
description:
|
|
85
|
+
'列出 SQLite 数据库文件与表结构,了解库里已有什么。' +
|
|
86
|
+
'返回 ~/.dsh/data/ 下的库文件清单、指定库(默认 default)中每张表的列定义与行数,并附完整性检查(quick_check)结果。' +
|
|
87
|
+
'何时用:不确定库里有哪些表时,先调用本工具,再决定查询、写入或导出。',
|
|
88
|
+
parameters: {
|
|
89
|
+
db: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: '库名,默认 "default"。',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
output: { schema: { type: 'string' }, render: renderText },
|
|
95
|
+
execute: catchText(async (args, exec) => {
|
|
96
|
+
const signal = exec && exec.signal
|
|
97
|
+
const info = engine.listTables(args.db, signal)
|
|
98
|
+
const files = engine.listDbFiles()
|
|
99
|
+
const lines = []
|
|
100
|
+
lines.push(`库文件(~/.dsh/data/):${files.length === 0 ? '尚无' : files.map((f) => `${f.name}(${f.bytes} 字节)`).join('、')}`)
|
|
101
|
+
lines.push('')
|
|
102
|
+
lines.push(`数据库 "${info.dbName}" 的表:`)
|
|
103
|
+
if (info.tables.length === 0) {
|
|
104
|
+
lines.push('(暂无表,可用 sqlite_exec 建表)')
|
|
105
|
+
}
|
|
106
|
+
for (const t of info.tables) {
|
|
107
|
+
const cols = t.columns.map((c) => `${c.name} ${c.type}${c.pk ? ' PK' : ''}${c.notnull ? ' NOT NULL' : ''}`).join(', ')
|
|
108
|
+
lines.push(`- ${t.name}:${t.rowCount} 行;列:${cols || '(无列信息)'}`)
|
|
109
|
+
}
|
|
110
|
+
lines.push('')
|
|
111
|
+
lines.push(`完整性检查(quick_check):${info.quickCheck}`)
|
|
112
|
+
return lines.join('\n')
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
export: {
|
|
117
|
+
name: 'sqlite_export',
|
|
118
|
+
description:
|
|
119
|
+
'把数据库导出为 SQL 文本文件,用于跨机器同步或备份(手动操作)。' +
|
|
120
|
+
'导出全部表,或用 tables 参数指定表名数组。' +
|
|
121
|
+
'默认写到 ~/.dsh/data/<名>.sql;to 参数可指定其它路径(如仓库内路径以便 git 提交,建议绝对路径)。' +
|
|
122
|
+
'返回目标路径、表数与行数。何时用:用户要求备份/同步,或你判断需要保存快照时。' +
|
|
123
|
+
'注意:部分导出时确认表间外键引用关系。',
|
|
124
|
+
parameters: {
|
|
125
|
+
db: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
description: '库名,默认 "default"。',
|
|
128
|
+
},
|
|
129
|
+
tables: {
|
|
130
|
+
type: 'array',
|
|
131
|
+
items: { type: 'string' },
|
|
132
|
+
description: '要导出的表名数组;省略 = 全部表。',
|
|
133
|
+
},
|
|
134
|
+
to: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: '目标文件路径(建议绝对路径);省略 = 本地默认路径 ~/.dsh/data/<名>.sql。',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
output: { schema: { type: 'string' }, render: renderText },
|
|
140
|
+
execute: catchText(async (args, exec) => {
|
|
141
|
+
const r = engine.exportToFile(args.db, args.tables, args.to, exec && exec.signal)
|
|
142
|
+
return `已导出 ${r.tables} 张表、${r.rows} 行 → ${r.path}`
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
import: {
|
|
147
|
+
name: 'sqlite_import',
|
|
148
|
+
description:
|
|
149
|
+
'从 SQL 文本文件恢复数据到数据库(覆盖式:替换文件中包含的表,库中其余表保留)。' +
|
|
150
|
+
'默认读 ~/.dsh/data/<名>.sql;from 参数指定源文件(建议绝对路径)。' +
|
|
151
|
+
'何时用:换机器后、或拉取仓库中的导出文件后恢复数据。导入前建议先 sqlite_export 备份当前数据。',
|
|
152
|
+
parameters: {
|
|
153
|
+
db: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
description: '目标库名,默认 "default"。',
|
|
156
|
+
},
|
|
157
|
+
from: {
|
|
158
|
+
type: 'string',
|
|
159
|
+
description: '源 SQL 文件路径(建议绝对路径);省略 = ~/.dsh/data/<名>.sql。',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
output: { schema: { type: 'string' }, render: renderText },
|
|
163
|
+
execute: catchText(async (args, exec) => {
|
|
164
|
+
const r = engine.importFromFile(args.db, args.from, exec && exec.signal)
|
|
165
|
+
return `导入完成:执行 ${r.statements} 条语句(${r.bytes} 字节)← ${r.path}`
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cxxl/dsh-sqlite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH plugin: persistent SQLite tools for the agent (sqlite_query / sqlite_exec / sqlite_tables / sqlite_export / sqlite_import) — zero runtime deps beyond Node 22 node:sqlite.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/index.js",
|
|
14
|
+
"lib/tools.js",
|
|
15
|
+
"lib/engine.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.5"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
25
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
26
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
27
|
+
},
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"dsh-plugin",
|
|
35
|
+
"sqlite",
|
|
36
|
+
"database",
|
|
37
|
+
"persistence",
|
|
38
|
+
"sync",
|
|
39
|
+
"deepseek-harness"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/happyCxxl/dsh-plugins.git",
|
|
45
|
+
"directory": "plugins/dsh-sqlite"
|
|
46
|
+
}
|
|
47
|
+
}
|