dirsql:用 SQL 把文件系统当数据库查询
Use Your Filesystem as a Database
作者发布 dirsql,一个用 SQL 查询本地文件的工具,把文件作为数据源,磁盘上增删文件时应用行数据自动更新,无需导出。它支持 SELECT * FROM './' 列出文件、按扩展名分组统计、用 --on-file 自定义解析器把 markdown 日记拆成行,并可通过 dirsql-plugin-embeddings 做向量语义检索。
如果你和我一样,你会用你的代理生成大量 markdown 文件。也许它们是研究文档或每日摘要之类的东西。它们可能是沟通工具,比如写入磁盘的计划,或者你分享的日志。也许它们是代理在每个会话开始时读取的记忆文件,或者是从一个会话到下一个会话的交接笔记。
Markdown 文件是我日常工作流程的核心,是我所做一切工作的原材料。它们便携、易读且易于备份。它们有很多值得推荐的地方。
与此同时,我为自己构建了大量应用,其中大多数都需要数据库。你无法像打开文本文件那样打开数据库并阅读它。我的代理必须先对数据库进行探查,然后才能读写任何内容,我也一样(或者让我的代理替我操作)。
而且由于数据通常以 markdown 开头,每个应用都需要一个脚本将数据导入应用并保持同步。如果我在应用中删除了一行,哪份副本是正确的?然后我又需要另一个脚本来导出数据,以备我停止使用这个凭感觉编写的应用的那一天(但仍然想保留我的数据)。
我构建了一个工具,dirsql,它让你用 SQL 查询你的文件。它就像数据的 React,一种以文件为真相来源声明式地派生数据库的方式。这意味着如果你在磁盘上添加或删除文件,应用的行会自动更新。如果你停止使用该应用,也无需导出任何内容,因为你的数据就是你的文件。你和你的代理在纯文本文件上协作,但你仍然可以构建我们所熟知和喜爱的动态软件,而无需后端机制。
我一直在自己的很多个人软件中使用它,我认为它可能对你的软件也有帮助。
当你的文件系统成为数据库时,你能做什么?
我认为展示它为何有用的最佳方式是展示我如何在自己的软件中使用 dirsql。如果你更喜欢技术性介绍,也有大量文档可供参考。1
让我们开始吧。
你可以 SELECT * FROM './'
通过 dirsql 使用 SQL 可以让你在文件系统上查找文件。
下面我将同时提供 dirsql 命令和原生等效命令。对于基本命令,原生工具更易读。越复杂,SQL 就越能体现其价值。
从基本列表开始:
dirsql
uvx dirsql "SELECT * FROM './'"
原生
ls
输出
path basename dir ext size mtime ctime
--------------- --------------- --- ---- ----- ---------- ----------
AGENTS.md AGENTS.md md 3941 1791240287 1791281924
ARCHITECTURE.md ARCHITECTURE.md md 23080 1791199872 1791281924
CHANGELOG.md CHANGELOG.md md 67821 1784394668 1791281924
Cargo.lock Cargo.lock lock 91276 1791224560 1791281924
你也可以轻松地用 ./** 递归:
dirsql
uvx dirsql "
SELECT * FROM './**'"
原生
find . -type f -not -path '*/.*'
输出
path basename dir ext size mtime ctime
----------------------------- --------------- ------------------- --- ----- ---------- ----------
AGENTS.md AGENTS.md md 3941 1791240287 1791281924
ARCHITECTURE.md ARCHITECTURE.md md 23080 1791199872 1791281924
…
agents/environments/local.md local.md agents/environments md 7450 1789758352 1791281924
agents/environments/remote.md remote.md agents/environments md 5037 1791224560 1791281924
…
在单个 SQL 命令中重新排序、限制和过滤:
dirsql
uvx dirsql "
SELECT path FROM './**'
WHERE ext = 'md'
ORDER BY mtime DESC
LIMIT 5"
原生
ls -t $(find . -name '*.md' -not -path '*/.*') | head -5
输出
path
-----------------------------------
PARITY.md
README.md
agents/reference/pr-monitor.md
agents/reference/session-handoff.md
docs/reference/cli.md
选择列并计算新列:
dirsql
uvx dirsql "
SELECT path, size / 1024 AS kb,
date(mtime, 'unixepoch') AS day
FROM './**'
WHERE ext = 'md'
ORDER BY size DESC
LIMIT 5"
原生
find . -name '*.md' -not -path '*/.*' \
-printf '%s %TF %P\n' \
| sort -rn \
| head -5 \
| awk '{ print $3, int($1 / 1024), $2 }'
输出
path kb day
--------------------------------------- -- ----------
MIGRATIONS.md 74 2026-09-02
CHANGELOG.md 66 2026-07-18
PARITY.md 49 2026-10-06
notes/recall-skill-review-2026-09-25.md 35 2026-09-28
agents/reference/testing-gates.md 32 2026-10-05
分组和计数:
dirsql
uvx dirsql "
SELECT ext,
count(*) AS files,
sum(size) / 1024 AS kb
FROM './**'
GROUP BY ext
ORDER BY files DESC
LIMIT 5"
原生
find . -type f -not -path '*/.*' \
-printf '%s %f\n' \
| awk '{
n = split($2, part, ".")
ext = n > 1 ? part[n] : "NULL"
files[ext]++
kb[ext] += $1
}
END {
for (e in files)
print e, files[e], int(kb[e] / 1024)
}' \
| sort -k2rn \
| head -5
输出
ext files kb
---- ----- ----
md 367 907
py 340 669
json 188 40
rs 104 1539
ts 77 259
你可以将多个查询拼接成一个列表:
dirsql
uvx dirsql "
SELECT 'files' AS stat, count(*) AS value
FROM './**'
UNION ALL
SELECT 'markdown', count(*)
FROM './**' WHERE ext = 'md'
UNION ALL
SELECT 'kb', sum(size) / 1024
FROM './**'
UNION ALL
SELECT 'newest', date(max(mtime), 'unixepoch')
FROM './**'"
原生
find . -type f -not -path '*/.*' \
-printf '%s\t%T@\t%P\n' \
| awk -F'\t' '{
files++; kb += $1
if ($3 ~ /\.md$/) md++
if ($2 > newest) newest = $2
}
END {
print "files", files
print "markdown", md
print "kb", int(kb / 1024)
print "newest", strftime("%Y-%m-%d", newest, 1)
}'
输出
stat value
-------- ----------
files 1106
markdown 367
kb 3728
newest 2026-10-06
SQL 真正出彩的地方在于组合规则或对列进行过滤,这使代理的行为更易读、更精确(同时消耗的 token 也更少!)
一个具体例子:我运行一个每日“档案生成”脚本。SQL 让我能精确控制传递给代理的文件列表。否则代理会自己用十几个非确定性的工具调用四处探查,这既消耗 token 又浪费时间2:
uvx dirsql "
SELECT 1 AS rank, 'standing' AS kind, path, date(mtime, 'unixepoch') AS modified
FROM './planner/background/*.md'
UNION ALL
SELECT 2, 'daily', path, date(mtime, 'unixepoch')
FROM './journal/*.md'
WHERE mtime > unixepoch('now', '-3 days')
UNION ALL
SELECT 3, 'recent', path, date(mtime, 'unixepoch')
FROM './**'
WHERE ext = 'md'
AND mtime > unixepoch('now', '-7 days')
AND path NOT LIKE 'journal/%'
AND path NOT LIKE 'planner/background/%'
ORDER BY rank, modified DESC"
输出
rank kind path modified
---- -------- ----------------------------------------- ----------
1 standing planner/background/projects.md 2026-09-10
1 standing planner/background/profile.md 2026-08-13
2 daily journal/2026-10-01.md 2026-10-01
2 daily journal/2026-09-30.md 2026-09-30
2 daily journal/2026-09-29.md 2026-09-30
3 recent planner/dockets/2026-09-30.md 2026-09-30
3 recent projects/dirsql/notes/launch-checklist.md 2026-09-29
3 recent projects/blog/drafts/dirsql-outline.md 2026-09-28
3 recent reading/sqlite-virtual-tables.md 2026-09-26
你的文本文件可以成为你的数据库
我特别喜欢这个用例。
dirsql 可以将你的文件内容转化为数据库。
假设你写日记、实验记录,或者某种按日期划分条目的文件:
# Journal of my time in the Antarctic
## 2026-09-22
Dang, saw some really cool Penguins today
## 2026-09-25
More penguins! How many penguins can one man see?
## 2026-09-26
Saw a walrus today.
## 2026-09-27
An albatross circled the ship for an hour.
## 2026-09-28
Ran out of coffee at the station. Morale is low.
能先看到最新的条目当然很好!让我们这样做:
uvx dirsql \
"SELECT date, content FROM './journal.md' ORDER BY date DESC" \
--on-file 'python3 parse.py'
这里有一个新标志,--on-file。它指定一个将文件内容转化为行的命令。在 Python 中,parse.py 可能看起来像:
import json, sys
for path in sys.argv[1:]:
for entry in open(path).read().split("\n## ")[1:]:
date, content = entry.split("\n", 1)
print(json.dumps({"date": date, "content": content.strip()}))
但这不一定非得是 Python,你可以使用系统支持的任何语言来进行解析:
uvx dirsql "SELECT date, content FROM './journal.md'
ORDER BY date DESC" \
--on-file "jq -Rsc '
[splits(\"\n## \")][1:]
| map(split(\"\n\n\"))
| map({date: .[0], content: (.[1] | rtrimstr(\"\n\"))})
| .[]
'"
其输出为:
输出
date content
---------- -------------------------------------------------
2026-09-28 Ran out of coffee at the station. Morale is low.
2026-09-27 An albatross circled the ship for an hour.
2026-09-26 Saw a walrus today.
2026-09-25 More penguins! How many penguins can one man see?
2026-09-22 Dang, saw some really cool Penguins today
当然,我们也可以使用原生 SQL。这个 notebook 确实经常提到企鹅;哪些条目没有提到企鹅呢?
uvx dirsql "SELECT date, content FROM './journal.md'
WHERE content NOT LIKE '%penguin%'" \
--on-file 'python3 parse.py'
输出
date content
---------- -------------------------------------------------
2026-09-26 Saw a walrus today.
2026-09-27 An albatross circled the ship for an hour.
2026-09-28 Ran out of coffee at the station. Morale is low.
你可以按语义搜索
因为我们使用的是 SQL(具体来说是 SQLite),所以我们可以访问整个 SQLite 生态系统。一件很酷的事情是我们可以使用向量嵌入。
向量嵌入是一种通过语义来表示文本的方式。你不再需要按关键词搜索(给我所有提到 penguins 的条目),而是可以按意图搜索(给我所有关于 polar birds 的条目)。
为了支持向量嵌入,我们需要包含一个插件(顺便说一句,dirsql 支持插件 3)。
让我们复用上面的日志。哪些条目讨论了 polar birds?
uvx --with dirsql-plugin-embeddings dirsql "
SELECT
round(vec_distance_cosine(
embed(content),
embed('polar birds')
), 3) AS distance,
date,
content
FROM './journal.md'
ORDER BY distance" --on-file 'python3 parse.py'
输出
distance date content
-------- ---------- -------------------------------------------------
0.637 2026-09-25 More penguins! How many penguins can one man see?
0.766 2026-09-22 Dang, saw some really cool Penguins today
0.789 2026-09-27 An albatross circled the ship for an hour.
0.821 2026-09-26 Saw a walrus today.
1.031 2026-09-28 Ran out of coffee at the station. Morale is low.
这是一个刻意构造的例子,所以让我们用更具体的例子。
arXiv 托管了大量学术论文,我有一个每晚运行的脚本,会把所有“机器学习”论文摘要拉取到磁盘上。使用 dirsql,我可以按语义搜索所有论文,这让找到相关且有趣的论文变得容易得多。
例如,我们可以搜索如何将自拍变成 3D 人脸:
uvx --with dirsql-plugin-embeddings dirsql "
SELECT
round(vec_distance_cosine(
embed(content ->> 'abstract'),
embed('turning a selfie into a 3D face')
), 3) AS distance,
substr(content ->> 'title', 1, 58) AS title
FROM './papers/*/metadata.json'
ORDER BY distance
LIMIT 5"
输出
distance title
-------- ----------------------------------------------------------
0.358 FaceGPT: Self-supervised Learning to Chat about 3D Human F
0.413 SARS: A Novel Face and Body Shape and Appearance Aware 3D
0.416 3DRealHead: Few-Shot Detailed Head Avatar
0.436 Generative Landmarks Guided Eyeglasses Removal 3D Face Rec
0.439 Generative Face Parsing Map Guided 3D Face Reconstruction
结果中没有一个包含 selfie。按关键词搜索会全部跳过它们。
你可以查询你的 Claude Code 对话记录
我每天都会使用的一个东西是我自己的个人对话记录数据库,它基本上不需要任何摄取管道就能免费获得。
Claude Code 将所有对话记录存储在一个本地目录 ~/.claude 中。让我们从中查询,看看它长什么样:
uvx dirsql "
SELECT * FROM '~/.claude/projects/*/*.jsonl'
ORDER BY mtime DESC
LIMIT 5"
输出
path basename dir ext size mtime ctime
------------------------------------------------------------- ------------------------------------------ ---------------------------------------------------- ----- ------- ---------- ----------
/home/you/.claude/projects/-home-you-code-recipe-app/3f9c2a7… 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13.jsonl /home/you/.claude/projects/-home-you-code-recipe-app jsonl 569836 1790958581 1790958384
/home/you/.claude/projects/-home-you-code-recipe-app/a81d4e0… a81d4e09-2c7f-4b3e-8f15-6e9a3c7b2d40.jsonl /home/you/.claude/projects/-home-you-code-recipe-app jsonl 5840861 1790958557 1790937682
/home/you/.claude/projects/-home-you-notes/c2e7b5f3-91a4-4f8… c2e7b5f3-91a4-4f8d-b026-3d8e1f6a9c57.jsonl /home/you/.claude/projects/-home-you-notes jsonl 745711 1790958488 1790898538
/home/you/.claude/projects/-home-you-code-budget-cli/7b40f8d… 7b40f8d2-5e3c-4a91-8d7e-0f2c6b9e4a18.jsonl /home/you/.claude/projects/-home-you-code-budget-cli jsonl 3368883 1790958469 1790865520
/home/you/.claude/projects/-home-you-notes/e95a1c3d-4f07-4b6… e95a1c3d-4f07-4b6e-a2d9-8c1f5e7b3a62.jsonl /home/you/.claude/projects/-home-you-notes jsonl 716330 1790958384 1790953793
Claude 按项目存储这些记录,并带有会话 ID。它还会将记忆和子代理对话记录存储在嵌套文件夹中。让我们调整查询来展示这一点:
uvx dirsql "
SELECT
CASE
WHEN path LIKE '%/memory/%' THEN 'memory'
WHEN path LIKE '%/subagents/%' THEN 'subagent transcript'
ELSE 'session transcript'
END AS kind,
count(*) AS files
FROM '~/.claude/projects/**'
WHERE ext = 'jsonl' OR path LIKE '%/memory/%'
GROUP BY kind
ORDER BY files DESC"
输出
kind files
------------------- -----
subagent transcript 1342
memory 490
session transcript 405
jsonl 文件将对话的每一轮存储为一行 JSON。让我们添加一个解析器来将它们拆开:
uvx dirsql "
SELECT
cwd AS project,
sessionId AS session,
type AS role,
CASE json_type(message, '$.content')
WHEN 'text' THEN message ->> '$.content'
ELSE message ->> '$.content[0].text'
END AS text,
timestamp
FROM '~/.claude/projects/*/*.jsonl'
WHERE text IS NOT NULL
ORDER BY timestamp DESC
LIMIT 5" --on-file 'cat'
输出
project session role text timestamp
------------------------- ------------------------------------ --------- --------------------------------------------------------- ------------------------
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant Let me look at the save handler. 2026-10-02T16:29:36.214Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 user Why does the ingredient list reorder itself on save? 2026-10-02T16:29:30.871Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant Done: the recipe page has an Export shopping list button. 2026-10-02T16:28:40.502Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant I'll add an export button below the ingredient list. 2026-10-02T16:27:20.093Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 user Add a shopping-list export to the recipe page 2026-10-02T16:27:12.647Z
理想情况下,我们会有一个规范化的布局,包含 messages 和 sessions 表。我们可以做到!我们需要一个配置文件:
[[table]]
name = "sessions"
glob = "*/*.jsonl"
ddl = "CREATE TABLE sessions (session TEXT, project TEXT)"
on-file = '''
jq -c -s '
map(select(.sessionId))
| group_by(.sessionId)
| map({
session: .[0].sessionId,
project: (map(.cwd | values) | first)
})
| .[]
'
'''
[[table]]
name = "messages"
glob = "*/*.jsonl"
ddl = "CREATE TABLE messages (session TEXT, role TEXT, text TEXT, timestamp TEXT)"
on-file = '''
jq -c '
{
session: .sessionId,
role: .type,
text: (.message.content | if type == "string" then . else .[0]?.text end),
timestamp
}
| select(.text)
'
'''
所以现在我们的查询变成了:
cd ~/.claude/projects
uvx dirsql "
SELECT s.project, m.session, m.role, m.text, m.timestamp
FROM messages m
JOIN sessions s ON s.session = m.session
ORDER BY m.timestamp DESC
LIMIT 5" -c ./.dirsql.toml
输出
project session role text timestamp
------------------------- ------------------------------------ --------- --------------------------------------------------------- ------------------------
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant Let me look at the save handler. 2026-10-02T16:29:36.214Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 user Why does the ingredient list reorder itself on save? 2026-10-02T16:29:30.871Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant Done: the recipe page has an Export shopping list button. 2026-10-02T16:28:40.502Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 assistant I'll add an export button below the ingredient list. 2026-10-02T16:27:20.093Z
/home/you/code/recipe-app 3f9c2a71-8e4b-4d2a-9c61-7b5e0d2f8a13 user Add a shopping-list export to the recipe page 2026-10-02T16:27:12.647Z
你也可以将 dirsql 作为服务器运行:
cd ~/.claude/projects
uvx dirsql server -c ./.dirsql.toml
> Running at localhost:7117
然后你可以对它运行 POST 请求。你可以让你的代理在此基础上构建一个前端,让你可以选择、重新排序、按语义搜索;随你所愿。dirsql 不提供前端 4,但它免费为你提供后端!
你的文件保持不变
归根结底,我对锁定深恶痛绝。
而这确实是我过去几年的生活趋势,尽可能避免我的数据被锁定。我从 Gmail 迁移到了自己的电子邮件域名,从 Evernote 迁移到了 Obsidian。我不喜欢我的数据被困在围墙花园里,这既适用于我自己的软件,也适用于别人的软件。
使用 dirsql,我的文件仍然属于我。我的日志仍然是 markdown 文件,arXiv 论文仍然是磁盘上的 JSON,Claude 对话记录仍然是我 ~/.claude 文件夹中的 JSONL,而代理们继续工作,全然不知上面有一个只读层通过 SQL 将它们暴露出来。而当我不可避免地决定扔掉我周末随手 vibe-code 出来的应用时,这不会影响我的数据,因为没有什么需要导出的。
在文档中查看 dirsql,或者直接在一个目录中尝试运行:
uvx dirsql "SELECT * FROM './'"
或者
npx -y dirsql "SELECT * FROM './'"
如果你使用它并且它对你有帮助(或者没有帮助!),请告诉我。
祝你 SQL 愉快!
dirsql可在 PyPI、npm 和 Cargo 上获取,并且可以通过uvx或npx免安装使用;入门说明在这里。 ↩︎当我在最近的一次 AI Tinkerers 活动上展示
dirsql时,有人问把dirsql交给一个 agent 是否会改变它的行为。我还没有一个好的答案。Agent 很少会自己主动去用它。我得提示它们。但一旦通过 skill 调用它,它们就用得很好。 ↩︎这个插件会自动引入一个向量嵌入模型。你也可以使用不同的模型。 ↩︎
也许应该这样?其实我喜欢这个想法,所以我把它作为一个 issue 提交了。 ↩︎
来源:Hacker News · thekevinscott.com