@tensorix/metahub 0.1.4 → 0.1.7

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.
@@ -0,0 +1,127 @@
1
+ # metahub
2
+
3
+ **给你的 AI 一个本地知识库。**
4
+
5
+ [English](./README.md) · 简体中文
6
+
7
+ metahub 是基于 Bun + SQLite 的本地优先知识库 CLI,给 AI agent 一份持久、可同步的工作记忆:用 notion 风格的**类型化数据表**装结构化数据,用 **markdown 文档**装长文本知识,再配一套像 `Read / Edit / Write` 的编辑接口。数据全部存在你本机的 `~/.metahub/`,单机离线可用,多机之间保持一致。
8
+
9
+ > 运行时依赖 [Bun](https://bun.sh)。用 `bunx`、全局安装后的 `mh`,或下载独立二进制都行。
10
+
11
+ ## 为什么用它
12
+
13
+ - **为 AI 读写而设计** —— `doc read` 先返回正文 + 版本号,`doc edit --old/--new` 做锚定查找替换、只传增量;引用一律支持 id 前缀 / 名字,不必粘完整 id。
14
+ - **结构化 + 非结构化一处管** —— 类型化数据表(行 / 列,8 种属性类型)放任务、流水、联系人;markdown 文档放设计稿、笔记。
15
+ - **本地优先、可同步** —— 数据在你自己的磁盘上,离线可用;多台机器干净合并,改不同段落互不覆盖。
16
+ - **自带 GUI 与 API** —— `mh --server` 一条命令起浏览器 WebUI(浏览 / 行内编辑 / 全文搜索)+ `/api/*` REST 接口 + 自动生成的 OpenAPI 文档。
17
+
18
+ <!-- TODO 截图:docs/assets/webui.png(WebUI)、docs/assets/desktop.png(桌面端) -->
19
+ > 想看界面?装上[桌面 App](#获取--安装),或运行 `mh --server` 后打开 `http://localhost:7777/`。
20
+
21
+ ## 快速开始
22
+
23
+ ```bash
24
+ bunx @tensorix/metahub init # 创建 ~/.metahub
25
+ mh db create "Tasks" # 建一张表 → 返回 id,如 db_tasks-k3f9c1
26
+ mh use tasks # 设为「当前库」,之后 record/prop 免带库参数
27
+ mh prop add Title --type text
28
+ mh prop add Status --type select --options "todo,doing,done"
29
+ mh record create --data '{"Title":"写设计稿","Status":"todo"}'
30
+ mh doc create --title "架构说明" --body @arch.md # @file / @- / 直接字符串
31
+ mh search "架构" # 全文检索文档 + 记录
32
+ ```
33
+
34
+ 引用不必粘完整 id:凡接受 id 的地方都接受**完整 id / 唯一前缀 / 名字**,歧义时报错列候选。
35
+
36
+ **给 AI 用的非交互增量编辑**(对标 `Read / Edit / Write`):
37
+
38
+ ```bash
39
+ mh doc read <ref> # 读正文 + version(改前先读)
40
+ mh doc edit <ref> --old "旧文本" --new "新文本" # 锚定查找替换,只传增量
41
+ mh doc append <ref> --body "追加段落" # 也有 prepend
42
+ ```
43
+
44
+ 输出按受众自动切换:终端(人)显示表格 / markdown,被管道或子进程调用(AI)输出 **JSON**;`--json` / `--pretty` 可强制。
45
+
46
+ ## 获取 / 安装
47
+
48
+ | 形态 | 安装 | 适合 |
49
+ | --- | --- | --- |
50
+ | **桌面 App**(GUI) | `brew install --cask tensorix/tap/metahub-app` | 想要图形界面、像 notion 那样用 |
51
+ | **CLI** | `brew install tensorix/tap/metahub-cli`,或 `npm i -g @tensorix/metahub`,或免安装 `bunx @tensorix/metahub <cmd>` | AI agent / 命令行 |
52
+ | **库**(Bun) | `bun add @tensorix/metahub` | 在自己的 Bun 程序里直接调用 |
53
+ | **独立二进制** | 下载对应平台文件,`chmod +x` 后直接运行 | 无运行时环境 |
54
+
55
+ ```ts
56
+ // 作为库使用:所有 core 能力从包根导出
57
+ import { openMetahub, createDatabase, createRecord, search } from "@tensorix/metahub";
58
+ ```
59
+
60
+ 支持平台:`darwin-arm64` / `darwin-x64` / `linux-x64` / `linux-arm64` / `windows-x64`。库 API 详见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
61
+
62
+ ## 多机同步
63
+
64
+ 在一台机器起服务端,另一台与它同步。文档按段落级合并,所以并发改同一篇文档的不同部分能干净合并。
65
+
66
+ ```bash
67
+ # A 机:启动同步服务端(它本身也是一个 metahub 节点)
68
+ mh --server --port 7777
69
+
70
+ # B 机:与服务端推 / 拉一轮
71
+ mh sync http://a-host:7777
72
+ ```
73
+
74
+ 配对一次后即可后台**周期性自动双向同步**,不必每次手敲 `mh sync`。同一条 `sync` 命令也能在「单个文档 / 数据表」与本地文件之间导入导出(文档 ↔ markdown、数据表 ↔ CSV)。原理详见[系统设计文档](./docs/system-design/)。
75
+
76
+ ## WebUI、API 与 Agent 站点
77
+
78
+ `mh --server` 在根路径 `/` 内置浏览器 **WebUI**(浏览 / 行内编辑数据表、块级所见即所得编辑文档、全文搜索)。同一服务端还暴露:
79
+
80
+ - `/api/*` —— REST 接口,读写本库的数据表与文档。
81
+ - `/docs` —— 自动生成的 OpenAPI 文档。
82
+ - `/sites/<name>/` —— `mh site publish` 托管 agent 生成的 HTML/CSS/JS;页面同源调用 `/api/*` 即可读本库数据(一个本地 mini-Supabase)。
83
+
84
+ 每个请求由单 token 守护(持久化在 `~/.metahub`)。服务端默认只绑 `127.0.0.1`;`--host 0.0.0.0` 才对外,此时凭据以明文 Bearer 传输,请置于可信网络或前置 TLS。细节见[系统设计文档](./docs/system-design/)。
85
+
86
+ ## 命令参考
87
+
88
+ <details>
89
+ <summary>展开完整命令表</summary>
90
+
91
+ | 命令 | 说明 |
92
+ | --- | --- |
93
+ | `mh init` | 创建 `~/.metahub` |
94
+ | `mh db create\|list\|get\|delete` | 管理数据库(表) |
95
+ | `mh use [<db>] [--clear]` | 设置/显示「当前库」(record/prop 默认作用于它) |
96
+ | `mh get <ref>` | 通用查找:按 id/前缀/名字解析,自动判别类型 |
97
+ | `mh prop add\|list\|update\|remove` | 管理属性(列);`add` 用 `--db` 指定库(默认当前库) |
98
+ | `mh record create\|list\|get\|update\|delete` | 管理记录(行) |
99
+ | `mh doc create\|list\|get\|update\|delete` | 管理 markdown 文档 |
100
+ | `mh doc read <id>` | 读正文 + version token(AI 改前先读) |
101
+ | `mh doc edit <id> --old --new` | 锚定查找替换(`--replace-all` / `--if-match`) |
102
+ | `mh doc append\|prepend <id> --body` | 在文档首/尾追加块 |
103
+ | `mh edit <id>` | 在 `$EDITOR` 中交互式编辑文档/记录(给人用) |
104
+ | `mh search <query>` | 全文检索(文档 + 记录) |
105
+ | `mh doctor` | 只读体检:列出逻辑完整性问题(孤儿引用/单元格、重复路径、文档环、重名等) |
106
+ | `mh repair [--dry-run]` | 确定性、幂等修复可自动修的问题(改动随 oplog 复制);`--dry-run` 仅预览(等价 doctor) |
107
+ | `mh site create\|put\|publish\|list\|files\|rm\|delete` | 托管 agent 生成的静态站点(HTML/CSS/JS),由 `--server` 在 `/sites/<name>/` serve 出去 |
108
+ | `mh token [show\|refresh]` | 查看 / 轮换持久化的服务器鉴权 token(存于 `~/.metahub`,默认 30 天到期轮换) |
109
+ | `mh completion <bash\|zsh\|fish>` | 打印补全脚本:`eval "$(mh completion zsh)"` |
110
+ | `mh sync <url>` | 与服务端同步一轮(CRDT 推/拉);`/sync` 受保护时按已存凭据直连,否则在交互终端提示输入 token 并记住(`--token` 非交互) |
111
+ | `mh sync <src> <dst>` | 单个文档/数据表与文件互导:文档↔markdown、数据表↔CSV;方向按参数判别(哪侧是库内实体) |
112
+ | `mh config` | 配置服务器与同步设备:无参进交互向导,`--flag` 直配(`--host/--port/--sync-interval/--auto-sync`) |
113
+ | `mh config peer code\|add\|list\|sync\|enable\|disable\|rm` | 多设备配对与管理:生成一次性配对码 / 配对 / 列出 / 立即同步 / 启停 / 移除(连带吊销签发给对方的凭据) |
114
+ | `mh config grant list\|revoke` | 列出 / 吊销本机签发的入站同步凭据(`revoke --token` 支持精确或前缀) |
115
+ | `mh --server [--port] [--host] [--debug] [--token] [--sync-interval] [--no-auto-sync]` | 启动服务端:`/sync`(主 token 或配对凭据)+ 根路径 WebUI + `/api/*` REST + `/docs`(OpenAPI)+ 静态站点 `/sites/<name>/` + token 交换 `/auth/token` + 配对 `/api/pair`;内置定时器自动同步已配对 peer |
116
+
117
+ </details>
118
+
119
+ ## 文档
120
+
121
+ - [系统设计](./docs/system-design/) —— 架构、数据模型、能力清单、使用流程。
122
+ - [贡献指南](./CONTRIBUTING.md) —— 本地开发、构建、发布、目录结构、库 API。
123
+ - [桌面 App](./apps/desktop/README.md) —— Electron 壳 + Bun sidecar。
124
+
125
+ ## 许可证
126
+
127
+ [AGPL-3.0-only](./LICENSE)。
package/dist/cli.js CHANGED
@@ -5,25 +5,43 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
8
13
  var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
9
21
  target = mod != null ? __create(__getProtoOf(mod)) : {};
10
22
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
23
  for (let key of __getOwnPropNames(mod))
12
24
  if (!__hasOwnProp.call(to, key))
13
25
  __defProp(to, key, {
14
- get: () => mod[key],
26
+ get: __accessProp.bind(mod, key),
15
27
  enumerable: true
16
28
  });
29
+ if (canCache)
30
+ cache.set(mod, to);
17
31
  return to;
18
32
  };
19
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __returnValue = (v) => v;
35
+ function __exportSetter(name, newValue) {
36
+ this[name] = __returnValue.bind(null, newValue);
37
+ }
20
38
  var __export = (target, all) => {
21
39
  for (var name in all)
22
40
  __defProp(target, name, {
23
41
  get: all[name],
24
42
  enumerable: true,
25
43
  configurable: true,
26
- set: (newValue) => all[name] = () => newValue
44
+ set: __exportSetter.bind(all, name)
27
45
  });
28
46
  };
29
47
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -667,7 +685,7 @@ var init_prompt = __esm(() => {
667
685
  Object.keys(r.modifier);
668
686
  tD = Object.keys(r.color);
669
687
  eD = Object.keys(r.bgColor);
670
- [...tD];
688
+ [...tD, ...eD];
671
689
  iD = sD();
672
690
  v = new Set(["\x1B", "\x9B"]);
673
691
  y = `${rD}8;;`;
@@ -891,7 +909,7 @@ function observeHlc(db, node, remote, now = Date.now()) {
891
909
  }
892
910
 
893
911
  // src/core/blocks.ts
894
- function parseBlocks(md) {
912
+ function parseDocBlocks(md) {
895
913
  if (!md)
896
914
  return [];
897
915
  const lines = md.replace(/\r\n?/g, `
@@ -901,24 +919,24 @@ function parseBlocks(md) {
901
919
  let cur = [];
902
920
  let fenceChar = null;
903
921
  let fenceLen = 0;
904
- const flushPara = () => {
905
- while (cur.length && cur[cur.length - 1].trim() === "")
906
- cur.pop();
907
- while (cur.length && cur[0].trim() === "")
908
- cur.shift();
909
- if (cur.length)
910
- blocks.push(cur.join(`
911
- `));
912
- cur = [];
922
+ const closeBlock = () => {
923
+ if (cur.length) {
924
+ blocks.push({ text: cur.join(`
925
+ `), blankAfter: 0 });
926
+ cur = [];
927
+ }
928
+ };
929
+ const onBlank = () => {
930
+ closeBlock();
931
+ if (blocks.length)
932
+ blocks[blocks.length - 1].blankAfter++;
913
933
  };
914
934
  for (const raw of lines) {
915
935
  if (fenceChar) {
916
936
  cur.push(raw);
917
937
  const m2 = raw.match(/^\s*(`{3,}|~{3,})\s*$/);
918
938
  if (m2 && m2[1][0] === fenceChar && m2[1].length >= fenceLen) {
919
- blocks.push(cur.join(`
920
- `));
921
- cur = [];
939
+ closeBlock();
922
940
  fenceChar = null;
923
941
  fenceLen = 0;
924
942
  }
@@ -926,28 +944,34 @@ function parseBlocks(md) {
926
944
  }
927
945
  const open = raw.match(/^\s*(`{3,}|~{3,})/);
928
946
  if (open) {
929
- flushPara();
947
+ closeBlock();
930
948
  fenceChar = open[1][0];
931
949
  fenceLen = open[1].length;
932
950
  cur.push(raw);
933
951
  } else if (raw.trim() === "") {
934
- flushPara();
952
+ onBlank();
935
953
  } else {
936
954
  cur.push(raw);
937
955
  }
938
956
  }
939
- if (fenceChar) {
940
- if (cur.length)
941
- blocks.push(cur.join(`
942
- `));
943
- } else {
944
- flushPara();
945
- }
957
+ closeBlock();
958
+ for (let i2 = 0;i2 < blocks.length - 1; i2++)
959
+ blocks[i2].blankAfter = Math.max(0, blocks[i2].blankAfter - 1);
946
960
  return blocks;
947
961
  }
948
- function serializeBlocks(texts) {
949
- return texts.filter((t2) => !!t2 && t2.length > 0).join(`
950
-
962
+ function serializeDocBlocks(blocks) {
963
+ const live = blocks.filter((b2) => !!b2.text && b2.text.length > 0);
964
+ const out = [];
965
+ live.forEach((b2, i2) => {
966
+ out.push(...b2.text.split(`
967
+ `));
968
+ const extra = Math.max(0, b2.blankAfter ?? 0);
969
+ if (i2 < live.length - 1)
970
+ out.push("");
971
+ for (let k2 = 0;k2 < extra; k2++)
972
+ out.push("");
973
+ });
974
+ return out.join(`
951
975
  `);
952
976
  }
953
977
  function reconcile(oldTexts, newTexts) {
@@ -1004,9 +1028,9 @@ function isBlockManaged(db, docId) {
1004
1028
  return db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId) != null;
1005
1029
  }
1006
1030
  function recomputeDocBody(db, docId) {
1007
- const rows = db.query("SELECT text FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
1031
+ const rows = db.query("SELECT text, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
1008
1032
  ensureRow(db, "documents", docId);
1009
- db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeBlocks(rows.map((r3) => r3.text)), docId);
1033
+ db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeDocBlocks(rows.map((r3) => ({ text: r3.text, blankAfter: r3.blank_after ?? 0 }))), docId);
1010
1034
  }
1011
1035
  function materialize(db, dataset, rowId, col, valueJson) {
1012
1036
  if (dataset === "records") {
@@ -1096,7 +1120,7 @@ var init_crdt = __esm(() => {
1096
1120
  },
1097
1121
  doc_blocks: {
1098
1122
  table: "doc_blocks",
1099
- cols: new Set(["doc_id", "text", "order_key", "__deleted"])
1123
+ cols: new Set(["doc_id", "text", "order_key", "blank_after", "__deleted"])
1100
1124
  },
1101
1125
  sites: {
1102
1126
  table: "sites",
@@ -1390,6 +1414,7 @@ async function serveWebui(req) {
1390
1414
  headers: { "content-type": "text/javascript; charset=utf-8" }
1391
1415
  });
1392
1416
  } catch (e3) {
1417
+ console.error("[webui] failed to serve /webui.js \u2014", e3);
1393
1418
  return new Response(`webui build failed: ${e3}`, { status: 500 });
1394
1419
  }
1395
1420
  }
@@ -1423,6 +1448,7 @@ var HTML = `<!doctype html>
1423
1448
  --radius:8px; --radius-sm:6px;
1424
1449
  --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
1425
1450
  --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
1451
+ --note-lh:1.6;
1426
1452
  --code-bg:#f8f8f6;
1427
1453
  --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
1428
1454
  --hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
@@ -1455,7 +1481,7 @@ var HTML = `<!doctype html>
1455
1481
  * { box-sizing: border-box; }
1456
1482
  html, body { margin: 0; height: 100%; }
1457
1483
  body { font-family: var(--ui); font-size: 14px; line-height: 1.55; color: var(--fg); background: var(--bg);
1458
- overflow: hidden; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
1484
+ overflow: hidden; text-rendering: auto; }
1459
1485
  button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
1460
1486
  input, textarea { font: inherit; color: inherit; }
1461
1487
  .muted { color: var(--muted); }
@@ -1511,6 +1537,8 @@ var HTML = `<!doctype html>
1511
1537
  .dragging { opacity:.35; }
1512
1538
  .sb-footer { flex:none; padding:6px 8px; border-top:1px solid var(--line); }
1513
1539
  .sb-footer .navitem { font-size:13.5px; }
1540
+ .nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
1541
+ box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
1514
1542
 
1515
1543
  /* settings page */
1516
1544
  .set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
@@ -1520,8 +1548,19 @@ var HTML = `<!doctype html>
1520
1548
  .set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
1521
1549
  .set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
1522
1550
  .set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
1523
- justify-content:center; gap:8px; color:var(--muted); font-size:12px; }
1524
- .set-footer-sep { opacity:.5; }
1551
+ flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
1552
+ .set-footer-sep { opacity:.45; }
1553
+ .ver-num { font-family:var(--mono); font-size:11px; color:var(--fg-soft); letter-spacing:-.01em; }
1554
+ .ver-up { display:inline-flex; align-items:center; gap:6px; }
1555
+ .ver-up.err .ver-msg { color:var(--danger); }
1556
+ .ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
1557
+ color:var(--muted); cursor:pointer; transition:color .12s; }
1558
+ .ver-act:hover { color:var(--accent); }
1559
+ .ver-act.accent { color:var(--accent); font-weight:600; }
1560
+ .ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
1561
+ .ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
1562
+ .ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
1563
+ @keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
1525
1564
  .theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
1526
1565
  .theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
1527
1566
  text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
@@ -1614,8 +1653,10 @@ var HTML = `<!doctype html>
1614
1653
  /* The list item's host gutter controls the whole unit, so suppress the nested
1615
1654
  code block's own gutter (it would overlap the list marker on the same row). */
1616
1655
  .block.list-code-host + .block-wrap.nested .gutter { display:none; }
1617
- .editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:1.6; }
1656
+ .editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
1618
1657
  .editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
1658
+ /* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
1659
+ .editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
1619
1660
  .b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
1620
1661
  .b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
1621
1662
  .b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
@@ -1667,7 +1708,8 @@ var HTML = `<!doctype html>
1667
1708
  .hljs-emphasis { font-style:italic; }
1668
1709
  .hljs-strong { font-weight:700; }
1669
1710
  .marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
1670
- .b-todo .marker { padding-top:5px; }
1711
+ .b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
1712
+ display:flex; align-items:center; justify-content:center; }
1671
1713
  .b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
1672
1714
  .b-divider { padding:10px 0; }
1673
1715
  .b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
@@ -1854,14 +1896,14 @@ var HTML = `<!doctype html>
1854
1896
  .optrow .x:hover { background:var(--hover-2); color:var(--danger); }
1855
1897
 
1856
1898
  /* modal */
1857
- .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:grid; place-items:center;
1899
+ .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
1858
1900
  opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
1859
1901
  .modal-scrim.open { opacity:1; pointer-events:auto; }
1860
- .modal { background:var(--bg); border-radius:16px; box-shadow:var(--shadow-lg); width:440px; max-width:100%; overflow:hidden; }
1902
+ .modal { background:var(--bg); border-radius:16px; box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
1861
1903
  .modal-head { padding:20px 22px 0; }
1862
1904
  .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
1863
1905
  .modal-head p { margin:6px 0 0; color:var(--muted); font-size:13.5px; }
1864
- .modal-body { padding:18px 22px; }
1906
+ .modal-body { padding:18px 22px; overflow-y:auto; }
1865
1907
  .modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
1866
1908
  .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
1867
1909
  .field-label:first-child { margin-top:0; }
@@ -2080,6 +2122,17 @@ var HTML = `<!doctype html>
2080
2122
  .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
2081
2123
  .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
2082
2124
  .qn-body .doc-meta { display:none; }
2125
+ /* A native checkbox paints an opaque white box when unchecked, which breaks
2126
+ the window's vibrancy blur. Draw a custom box so the unchecked background
2127
+ stays transparent; checked falls back to the accent fill + checkmark. */
2128
+ body.quicknote .b-todo .marker input {
2129
+ appearance:none; -webkit-appearance:none; box-sizing:border-box;
2130
+ border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
2131
+ border-radius:4px; background:transparent; }
2132
+ body.quicknote .b-todo .marker input:checked {
2133
+ border-color:var(--accent);
2134
+ background:var(--accent) center/10px no-repeat
2135
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
2083
2136
  /* Compact menus inside the quick-note window only (dropdown + slash command
2084
2137
  share .pop/.item/.lico with the main app; scope under body.quicknote so the
2085
2138
  main app keeps its roomier density). */
@@ -3540,8 +3593,9 @@ async function runMain(cmd, opts = {}) {
3540
3593
  // package.json
3541
3594
  var package_default = {
3542
3595
  name: "@tensorix/metahub",
3543
- version: "0.1.4",
3596
+ version: "0.1.7",
3544
3597
  description: "Metahub core library and CLI",
3598
+ license: "AGPL-3.0-only",
3545
3599
  type: "module",
3546
3600
  main: "./dist/index.js",
3547
3601
  module: "./dist/index.js",
@@ -3559,7 +3613,9 @@ var package_default = {
3559
3613
  },
3560
3614
  files: [
3561
3615
  "dist",
3562
- "README.md"
3616
+ "README.md",
3617
+ "README.zh-CN.md",
3618
+ "LICENSE"
3563
3619
  ],
3564
3620
  scripts: {
3565
3621
  dev: "bun --hot src/cli/index.ts",
@@ -3692,11 +3748,12 @@ CREATE TABLE IF NOT EXISTS documents (
3692
3748
  -- independent CRDT register so concurrent edits to different blocks merge.
3693
3749
  -- order_key is a fractional index (ties broken by id) giving display order.
3694
3750
  CREATE TABLE IF NOT EXISTS doc_blocks (
3695
- id TEXT PRIMARY KEY,
3696
- doc_id TEXT,
3697
- text TEXT,
3698
- order_key TEXT,
3699
- __deleted INTEGER NOT NULL DEFAULT 0
3751
+ id TEXT PRIMARY KEY,
3752
+ doc_id TEXT,
3753
+ text TEXT,
3754
+ order_key TEXT,
3755
+ blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
3756
+ __deleted INTEGER NOT NULL DEFAULT 0
3700
3757
  );
3701
3758
  CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
3702
3759
 
@@ -4384,6 +4441,10 @@ function migratePeers(db) {
4384
4441
  db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
4385
4442
  }
4386
4443
  }
4444
+ function migrateDocBlocks(db) {
4445
+ if (!hasColumn(db, "doc_blocks", "blank_after"))
4446
+ db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
4447
+ }
4387
4448
  function openMetahub() {
4388
4449
  ensureDirs();
4389
4450
  const db = new Database(dbPath(), { create: true });
@@ -4391,6 +4452,7 @@ function openMetahub() {
4391
4452
  runSchema(db);
4392
4453
  migrateRecords(db);
4393
4454
  migratePeers(db);
4455
+ migrateDocBlocks(db);
4394
4456
  return db;
4395
4457
  }
4396
4458
 
@@ -4562,7 +4624,7 @@ var use_default = defineCommand({
4562
4624
  init_ids();
4563
4625
  init_crdt();
4564
4626
  function liveBlocks(db, docId) {
4565
- return db.query("SELECT id, text, order_key FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
4627
+ return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
4566
4628
  }
4567
4629
  function makeBlockId(text) {
4568
4630
  return newId("blk", text.split(`
@@ -4572,29 +4634,37 @@ function emitBlock(db, docId, fields) {
4572
4634
  const id = makeBlockId(fields.text);
4573
4635
  emit(db, "doc_blocks", id, "doc_id", docId);
4574
4636
  emit(db, "doc_blocks", id, "order_key", fields.order_key);
4637
+ if (fields.blankAfter)
4638
+ emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
4575
4639
  emit(db, "doc_blocks", id, "text", fields.text);
4576
4640
  }
4577
- function insertBlocks(db, docId, texts, after, before) {
4578
- const keys = keysBetween(after, before, texts.length);
4579
- texts.forEach((text, i2) => emitBlock(db, docId, { text, order_key: keys[i2] }));
4641
+ function insertBlocks(db, docId, blocks, after, before) {
4642
+ const keys = keysBetween(after, before, blocks.length);
4643
+ blocks.forEach((b2, i2) => emitBlock(db, docId, { text: b2.text, order_key: keys[i2], blankAfter: b2.blankAfter }));
4580
4644
  }
4581
4645
  function ensureBlocks(db, docId) {
4582
4646
  if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
4583
4647
  return;
4584
4648
  const row = db.query("SELECT body FROM documents WHERE id = ?").get(docId);
4585
- const texts = parseBlocks(row?.body ?? "");
4586
- if (texts.length)
4587
- insertBlocks(db, docId, texts, null, null);
4649
+ const blocks = parseDocBlocks(row?.body ?? "");
4650
+ if (blocks.length)
4651
+ insertBlocks(db, docId, blocks, null, null);
4588
4652
  }
4589
4653
  function reconcileBody(db, docId, body) {
4590
4654
  const old = liveBlocks(db, docId);
4591
- const plan = reconcile(old.map((b2) => b2.text), parseBlocks(body));
4655
+ const next = parseDocBlocks(body);
4656
+ const plan = reconcile(old.map((b2) => b2.text), next.map((b2) => b2.text));
4592
4657
  for (const oi of plan.deleted)
4593
4658
  emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
4594
4659
  const { items } = plan;
4595
4660
  let i2 = 0;
4596
4661
  while (i2 < items.length) {
4597
- if ("keep" in items[i2]) {
4662
+ const it = items[i2];
4663
+ if ("keep" in it) {
4664
+ const oldB = old[it.keep];
4665
+ const want = next[i2].blankAfter;
4666
+ if ((oldB.blank_after ?? 0) !== want)
4667
+ emit(db, "doc_blocks", oldB.id, "blank_after", want);
4598
4668
  i2++;
4599
4669
  continue;
4600
4670
  }
@@ -4603,8 +4673,7 @@ function reconcileBody(db, docId, body) {
4603
4673
  j++;
4604
4674
  const left = i2 > 0 ? old[items[i2 - 1].keep].order_key : null;
4605
4675
  const right = j < items.length ? old[items[j].keep].order_key : null;
4606
- const texts = items.slice(i2, j).map((it) => it.insert);
4607
- insertBlocks(db, docId, texts, left, right);
4676
+ insertBlocks(db, docId, next.slice(i2, j), left, right);
4608
4677
  i2 = j;
4609
4678
  }
4610
4679
  }
@@ -4623,9 +4692,9 @@ function createDocument(db, opts) {
4623
4692
  if (opts.parent_id !== undefined)
4624
4693
  emit(db, "documents", id, "parent_id", opts.parent_id);
4625
4694
  if (opts.body !== undefined) {
4626
- const texts = parseBlocks(opts.body);
4627
- if (texts.length)
4628
- insertBlocks(db, id, texts, null, null);
4695
+ const blocks = parseDocBlocks(opts.body);
4696
+ if (blocks.length)
4697
+ insertBlocks(db, id, blocks, null, null);
4629
4698
  }
4630
4699
  return getDocument(db, id);
4631
4700
  }
@@ -4671,7 +4740,7 @@ function editDocument(db, id, opts) {
4671
4740
  throw new Error("--old must not be empty");
4672
4741
  ensureBlocks(db, id);
4673
4742
  const blocks = liveBlocks(db, id);
4674
- const body = serializeBlocks(blocks.map((b2) => b2.text));
4743
+ const body = serializeDocBlocks(blocks.map((b2) => ({ text: b2.text, blankAfter: b2.blank_after })));
4675
4744
  const count = countOccurrences(body, opts.old);
4676
4745
  if (count === 0)
4677
4746
  throw new Error(`anchor not found: no match for --old`);
@@ -4698,25 +4767,25 @@ function appendDocument(db, id, body) {
4698
4767
  if (!getDocument(db, id))
4699
4768
  throw new Error(`no such document: ${id}`);
4700
4769
  ensureBlocks(db, id);
4701
- const texts = parseBlocks(body);
4702
- if (texts.length) {
4770
+ const next = parseDocBlocks(body);
4771
+ if (next.length) {
4703
4772
  const blocks = liveBlocks(db, id);
4704
4773
  const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
4705
- insertBlocks(db, id, texts, last, null);
4774
+ insertBlocks(db, id, next, last, null);
4706
4775
  }
4707
- return { id, changed: texts.length > 0, replaced: texts.length, version: documentVersion(db, id) };
4776
+ return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
4708
4777
  }
4709
4778
  function prependDocument(db, id, body) {
4710
4779
  if (!getDocument(db, id))
4711
4780
  throw new Error(`no such document: ${id}`);
4712
4781
  ensureBlocks(db, id);
4713
- const texts = parseBlocks(body);
4714
- if (texts.length) {
4782
+ const next = parseDocBlocks(body);
4783
+ if (next.length) {
4715
4784
  const blocks = liveBlocks(db, id);
4716
4785
  const first = blocks.length ? blocks[0].order_key : null;
4717
- insertBlocks(db, id, texts, null, first);
4786
+ insertBlocks(db, id, next, null, first);
4718
4787
  }
4719
- return { id, changed: texts.length > 0, replaced: texts.length, version: documentVersion(db, id) };
4788
+ return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
4720
4789
  }
4721
4790
  function deleteDocument(db, id) {
4722
4791
  if (!getDocument(db, id))
@@ -17211,7 +17280,7 @@ function finalize(ctx, schema) {
17211
17280
  result.$schema = "http://json-schema.org/draft-07/schema#";
17212
17281
  } else if (ctx.target === "draft-04") {
17213
17282
  result.$schema = "http://json-schema.org/draft-04/schema#";
17214
- } else if (ctx.target === "openapi-3.0") {} else {}
17283
+ } else if (ctx.target === "openapi-3.0") {}
17215
17284
  if (ctx.external?.uri) {
17216
17285
  const id = ctx.external.registry.get(schema)?.id;
17217
17286
  if (!id)
@@ -17455,7 +17524,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
17455
17524
  if (val === undefined) {
17456
17525
  if (ctx.unrepresentable === "throw") {
17457
17526
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
17458
- } else {}
17527
+ }
17459
17528
  } else if (typeof val === "bigint") {
17460
17529
  if (ctx.unrepresentable === "throw") {
17461
17530
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -23482,7 +23551,7 @@ function startServer(opts = {}) {
23482
23551
  let timer = null;
23483
23552
  if (autoSync && syncIntervalMs > 0) {
23484
23553
  timer = setInterval(() => {
23485
- syncAllPeers(db).catch(() => {});
23554
+ syncAllPeers(db).catch((e3) => console.error("[sync] auto-sync tick failed \u2014", e3));
23486
23555
  }, syncIntervalMs);
23487
23556
  timer.unref?.();
23488
23557
  }
@@ -23922,4 +23991,4 @@ if (argv2.includes("--server")) {
23922
23991
  runMain(main, { showUsage: showUsage2 });
23923
23992
  }
23924
23993
 
23925
- //# debugId=74D4BFCB8CE1AB2064756E2164756E21
23994
+ //# debugId=6697905C98729D8364756E2164756E21