@tensorix/metahub 0.1.4 → 0.1.6

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
@@ -891,7 +891,7 @@ function observeHlc(db, node, remote, now = Date.now()) {
891
891
  }
892
892
 
893
893
  // src/core/blocks.ts
894
- function parseBlocks(md) {
894
+ function parseDocBlocks(md) {
895
895
  if (!md)
896
896
  return [];
897
897
  const lines = md.replace(/\r\n?/g, `
@@ -901,24 +901,24 @@ function parseBlocks(md) {
901
901
  let cur = [];
902
902
  let fenceChar = null;
903
903
  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 = [];
904
+ const closeBlock = () => {
905
+ if (cur.length) {
906
+ blocks.push({ text: cur.join(`
907
+ `), blankAfter: 0 });
908
+ cur = [];
909
+ }
910
+ };
911
+ const onBlank = () => {
912
+ closeBlock();
913
+ if (blocks.length)
914
+ blocks[blocks.length - 1].blankAfter++;
913
915
  };
914
916
  for (const raw of lines) {
915
917
  if (fenceChar) {
916
918
  cur.push(raw);
917
919
  const m2 = raw.match(/^\s*(`{3,}|~{3,})\s*$/);
918
920
  if (m2 && m2[1][0] === fenceChar && m2[1].length >= fenceLen) {
919
- blocks.push(cur.join(`
920
- `));
921
- cur = [];
921
+ closeBlock();
922
922
  fenceChar = null;
923
923
  fenceLen = 0;
924
924
  }
@@ -926,28 +926,34 @@ function parseBlocks(md) {
926
926
  }
927
927
  const open = raw.match(/^\s*(`{3,}|~{3,})/);
928
928
  if (open) {
929
- flushPara();
929
+ closeBlock();
930
930
  fenceChar = open[1][0];
931
931
  fenceLen = open[1].length;
932
932
  cur.push(raw);
933
933
  } else if (raw.trim() === "") {
934
- flushPara();
934
+ onBlank();
935
935
  } else {
936
936
  cur.push(raw);
937
937
  }
938
938
  }
939
- if (fenceChar) {
940
- if (cur.length)
941
- blocks.push(cur.join(`
942
- `));
943
- } else {
944
- flushPara();
945
- }
939
+ closeBlock();
940
+ for (let i2 = 0;i2 < blocks.length - 1; i2++)
941
+ blocks[i2].blankAfter = Math.max(0, blocks[i2].blankAfter - 1);
946
942
  return blocks;
947
943
  }
948
- function serializeBlocks(texts) {
949
- return texts.filter((t2) => !!t2 && t2.length > 0).join(`
950
-
944
+ function serializeDocBlocks(blocks) {
945
+ const live = blocks.filter((b2) => !!b2.text && b2.text.length > 0);
946
+ const out = [];
947
+ live.forEach((b2, i2) => {
948
+ out.push(...b2.text.split(`
949
+ `));
950
+ const extra = Math.max(0, b2.blankAfter ?? 0);
951
+ if (i2 < live.length - 1)
952
+ out.push("");
953
+ for (let k2 = 0;k2 < extra; k2++)
954
+ out.push("");
955
+ });
956
+ return out.join(`
951
957
  `);
952
958
  }
953
959
  function reconcile(oldTexts, newTexts) {
@@ -1004,9 +1010,9 @@ function isBlockManaged(db, docId) {
1004
1010
  return db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId) != null;
1005
1011
  }
1006
1012
  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);
1013
+ 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
1014
  ensureRow(db, "documents", docId);
1009
- db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeBlocks(rows.map((r3) => r3.text)), docId);
1015
+ db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeDocBlocks(rows.map((r3) => ({ text: r3.text, blankAfter: r3.blank_after ?? 0 }))), docId);
1010
1016
  }
1011
1017
  function materialize(db, dataset, rowId, col, valueJson) {
1012
1018
  if (dataset === "records") {
@@ -1096,7 +1102,7 @@ var init_crdt = __esm(() => {
1096
1102
  },
1097
1103
  doc_blocks: {
1098
1104
  table: "doc_blocks",
1099
- cols: new Set(["doc_id", "text", "order_key", "__deleted"])
1105
+ cols: new Set(["doc_id", "text", "order_key", "blank_after", "__deleted"])
1100
1106
  },
1101
1107
  sites: {
1102
1108
  table: "sites",
@@ -1423,6 +1429,7 @@ var HTML = `<!doctype html>
1423
1429
  --radius:8px; --radius-sm:6px;
1424
1430
  --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
1425
1431
  --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
1432
+ --note-lh:1.6;
1426
1433
  --code-bg:#f8f8f6;
1427
1434
  --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
1428
1435
  --hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
@@ -1455,7 +1462,7 @@ var HTML = `<!doctype html>
1455
1462
  * { box-sizing: border-box; }
1456
1463
  html, body { margin: 0; height: 100%; }
1457
1464
  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; }
1465
+ overflow: hidden; text-rendering: auto; }
1459
1466
  button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
1460
1467
  input, textarea { font: inherit; color: inherit; }
1461
1468
  .muted { color: var(--muted); }
@@ -1511,6 +1518,8 @@ var HTML = `<!doctype html>
1511
1518
  .dragging { opacity:.35; }
1512
1519
  .sb-footer { flex:none; padding:6px 8px; border-top:1px solid var(--line); }
1513
1520
  .sb-footer .navitem { font-size:13.5px; }
1521
+ .nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
1522
+ box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
1514
1523
 
1515
1524
  /* settings page */
1516
1525
  .set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
@@ -1520,8 +1529,19 @@ var HTML = `<!doctype html>
1520
1529
  .set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
1521
1530
  .set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
1522
1531
  .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; }
1532
+ flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
1533
+ .set-footer-sep { opacity:.45; }
1534
+ .ver-num { font-family:var(--mono); font-size:11px; color:var(--fg-soft); letter-spacing:-.01em; }
1535
+ .ver-up { display:inline-flex; align-items:center; gap:6px; }
1536
+ .ver-up.err .ver-msg { color:var(--danger); }
1537
+ .ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
1538
+ color:var(--muted); cursor:pointer; transition:color .12s; }
1539
+ .ver-act:hover { color:var(--accent); }
1540
+ .ver-act.accent { color:var(--accent); font-weight:600; }
1541
+ .ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
1542
+ .ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
1543
+ .ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
1544
+ @keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
1525
1545
  .theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
1526
1546
  .theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
1527
1547
  text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
@@ -1614,8 +1634,10 @@ var HTML = `<!doctype html>
1614
1634
  /* The list item's host gutter controls the whole unit, so suppress the nested
1615
1635
  code block's own gutter (it would overlap the list marker on the same row). */
1616
1636
  .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; }
1637
+ .editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
1618
1638
  .editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
1639
+ /* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
1640
+ .editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
1619
1641
  .b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
1620
1642
  .b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
1621
1643
  .b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
@@ -1667,7 +1689,8 @@ var HTML = `<!doctype html>
1667
1689
  .hljs-emphasis { font-style:italic; }
1668
1690
  .hljs-strong { font-weight:700; }
1669
1691
  .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; }
1692
+ .b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
1693
+ display:flex; align-items:center; justify-content:center; }
1671
1694
  .b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
1672
1695
  .b-divider { padding:10px 0; }
1673
1696
  .b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
@@ -1854,14 +1877,14 @@ var HTML = `<!doctype html>
1854
1877
  .optrow .x:hover { background:var(--hover-2); color:var(--danger); }
1855
1878
 
1856
1879
  /* modal */
1857
- .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:grid; place-items:center;
1880
+ .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
1858
1881
  opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
1859
1882
  .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; }
1883
+ .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
1884
  .modal-head { padding:20px 22px 0; }
1862
1885
  .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
1863
1886
  .modal-head p { margin:6px 0 0; color:var(--muted); font-size:13.5px; }
1864
- .modal-body { padding:18px 22px; }
1887
+ .modal-body { padding:18px 22px; overflow-y:auto; }
1865
1888
  .modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
1866
1889
  .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
1867
1890
  .field-label:first-child { margin-top:0; }
@@ -2080,6 +2103,17 @@ var HTML = `<!doctype html>
2080
2103
  .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
2081
2104
  .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
2082
2105
  .qn-body .doc-meta { display:none; }
2106
+ /* A native checkbox paints an opaque white box when unchecked, which breaks
2107
+ the window's vibrancy blur. Draw a custom box so the unchecked background
2108
+ stays transparent; checked falls back to the accent fill + checkmark. */
2109
+ body.quicknote .b-todo .marker input {
2110
+ appearance:none; -webkit-appearance:none; box-sizing:border-box;
2111
+ border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
2112
+ border-radius:4px; background:transparent; }
2113
+ body.quicknote .b-todo .marker input:checked {
2114
+ border-color:var(--accent);
2115
+ background:var(--accent) center/10px no-repeat
2116
+ 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
2117
  /* Compact menus inside the quick-note window only (dropdown + slash command
2084
2118
  share .pop/.item/.lico with the main app; scope under body.quicknote so the
2085
2119
  main app keeps its roomier density). */
@@ -3540,8 +3574,9 @@ async function runMain(cmd, opts = {}) {
3540
3574
  // package.json
3541
3575
  var package_default = {
3542
3576
  name: "@tensorix/metahub",
3543
- version: "0.1.4",
3577
+ version: "0.1.6",
3544
3578
  description: "Metahub core library and CLI",
3579
+ license: "AGPL-3.0-only",
3545
3580
  type: "module",
3546
3581
  main: "./dist/index.js",
3547
3582
  module: "./dist/index.js",
@@ -3559,7 +3594,9 @@ var package_default = {
3559
3594
  },
3560
3595
  files: [
3561
3596
  "dist",
3562
- "README.md"
3597
+ "README.md",
3598
+ "README.zh-CN.md",
3599
+ "LICENSE"
3563
3600
  ],
3564
3601
  scripts: {
3565
3602
  dev: "bun --hot src/cli/index.ts",
@@ -3692,11 +3729,12 @@ CREATE TABLE IF NOT EXISTS documents (
3692
3729
  -- independent CRDT register so concurrent edits to different blocks merge.
3693
3730
  -- order_key is a fractional index (ties broken by id) giving display order.
3694
3731
  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
3732
+ id TEXT PRIMARY KEY,
3733
+ doc_id TEXT,
3734
+ text TEXT,
3735
+ order_key TEXT,
3736
+ blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
3737
+ __deleted INTEGER NOT NULL DEFAULT 0
3700
3738
  );
3701
3739
  CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
3702
3740
 
@@ -4384,6 +4422,10 @@ function migratePeers(db) {
4384
4422
  db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
4385
4423
  }
4386
4424
  }
4425
+ function migrateDocBlocks(db) {
4426
+ if (!hasColumn(db, "doc_blocks", "blank_after"))
4427
+ db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
4428
+ }
4387
4429
  function openMetahub() {
4388
4430
  ensureDirs();
4389
4431
  const db = new Database(dbPath(), { create: true });
@@ -4391,6 +4433,7 @@ function openMetahub() {
4391
4433
  runSchema(db);
4392
4434
  migrateRecords(db);
4393
4435
  migratePeers(db);
4436
+ migrateDocBlocks(db);
4394
4437
  return db;
4395
4438
  }
4396
4439
 
@@ -4562,7 +4605,7 @@ var use_default = defineCommand({
4562
4605
  init_ids();
4563
4606
  init_crdt();
4564
4607
  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);
4608
+ 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
4609
  }
4567
4610
  function makeBlockId(text) {
4568
4611
  return newId("blk", text.split(`
@@ -4572,29 +4615,37 @@ function emitBlock(db, docId, fields) {
4572
4615
  const id = makeBlockId(fields.text);
4573
4616
  emit(db, "doc_blocks", id, "doc_id", docId);
4574
4617
  emit(db, "doc_blocks", id, "order_key", fields.order_key);
4618
+ if (fields.blankAfter)
4619
+ emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
4575
4620
  emit(db, "doc_blocks", id, "text", fields.text);
4576
4621
  }
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] }));
4622
+ function insertBlocks(db, docId, blocks, after, before) {
4623
+ const keys = keysBetween(after, before, blocks.length);
4624
+ blocks.forEach((b2, i2) => emitBlock(db, docId, { text: b2.text, order_key: keys[i2], blankAfter: b2.blankAfter }));
4580
4625
  }
4581
4626
  function ensureBlocks(db, docId) {
4582
4627
  if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
4583
4628
  return;
4584
4629
  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);
4630
+ const blocks = parseDocBlocks(row?.body ?? "");
4631
+ if (blocks.length)
4632
+ insertBlocks(db, docId, blocks, null, null);
4588
4633
  }
4589
4634
  function reconcileBody(db, docId, body) {
4590
4635
  const old = liveBlocks(db, docId);
4591
- const plan = reconcile(old.map((b2) => b2.text), parseBlocks(body));
4636
+ const next = parseDocBlocks(body);
4637
+ const plan = reconcile(old.map((b2) => b2.text), next.map((b2) => b2.text));
4592
4638
  for (const oi of plan.deleted)
4593
4639
  emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
4594
4640
  const { items } = plan;
4595
4641
  let i2 = 0;
4596
4642
  while (i2 < items.length) {
4597
- if ("keep" in items[i2]) {
4643
+ const it = items[i2];
4644
+ if ("keep" in it) {
4645
+ const oldB = old[it.keep];
4646
+ const want = next[i2].blankAfter;
4647
+ if ((oldB.blank_after ?? 0) !== want)
4648
+ emit(db, "doc_blocks", oldB.id, "blank_after", want);
4598
4649
  i2++;
4599
4650
  continue;
4600
4651
  }
@@ -4603,8 +4654,7 @@ function reconcileBody(db, docId, body) {
4603
4654
  j++;
4604
4655
  const left = i2 > 0 ? old[items[i2 - 1].keep].order_key : null;
4605
4656
  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);
4657
+ insertBlocks(db, docId, next.slice(i2, j), left, right);
4608
4658
  i2 = j;
4609
4659
  }
4610
4660
  }
@@ -4623,9 +4673,9 @@ function createDocument(db, opts) {
4623
4673
  if (opts.parent_id !== undefined)
4624
4674
  emit(db, "documents", id, "parent_id", opts.parent_id);
4625
4675
  if (opts.body !== undefined) {
4626
- const texts = parseBlocks(opts.body);
4627
- if (texts.length)
4628
- insertBlocks(db, id, texts, null, null);
4676
+ const blocks = parseDocBlocks(opts.body);
4677
+ if (blocks.length)
4678
+ insertBlocks(db, id, blocks, null, null);
4629
4679
  }
4630
4680
  return getDocument(db, id);
4631
4681
  }
@@ -4671,7 +4721,7 @@ function editDocument(db, id, opts) {
4671
4721
  throw new Error("--old must not be empty");
4672
4722
  ensureBlocks(db, id);
4673
4723
  const blocks = liveBlocks(db, id);
4674
- const body = serializeBlocks(blocks.map((b2) => b2.text));
4724
+ const body = serializeDocBlocks(blocks.map((b2) => ({ text: b2.text, blankAfter: b2.blank_after })));
4675
4725
  const count = countOccurrences(body, opts.old);
4676
4726
  if (count === 0)
4677
4727
  throw new Error(`anchor not found: no match for --old`);
@@ -4698,25 +4748,25 @@ function appendDocument(db, id, body) {
4698
4748
  if (!getDocument(db, id))
4699
4749
  throw new Error(`no such document: ${id}`);
4700
4750
  ensureBlocks(db, id);
4701
- const texts = parseBlocks(body);
4702
- if (texts.length) {
4751
+ const next = parseDocBlocks(body);
4752
+ if (next.length) {
4703
4753
  const blocks = liveBlocks(db, id);
4704
4754
  const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
4705
- insertBlocks(db, id, texts, last, null);
4755
+ insertBlocks(db, id, next, last, null);
4706
4756
  }
4707
- return { id, changed: texts.length > 0, replaced: texts.length, version: documentVersion(db, id) };
4757
+ return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
4708
4758
  }
4709
4759
  function prependDocument(db, id, body) {
4710
4760
  if (!getDocument(db, id))
4711
4761
  throw new Error(`no such document: ${id}`);
4712
4762
  ensureBlocks(db, id);
4713
- const texts = parseBlocks(body);
4714
- if (texts.length) {
4763
+ const next = parseDocBlocks(body);
4764
+ if (next.length) {
4715
4765
  const blocks = liveBlocks(db, id);
4716
4766
  const first = blocks.length ? blocks[0].order_key : null;
4717
- insertBlocks(db, id, texts, null, first);
4767
+ insertBlocks(db, id, next, null, first);
4718
4768
  }
4719
- return { id, changed: texts.length > 0, replaced: texts.length, version: documentVersion(db, id) };
4769
+ return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
4720
4770
  }
4721
4771
  function deleteDocument(db, id) {
4722
4772
  if (!getDocument(db, id))
@@ -23922,4 +23972,4 @@ if (argv2.includes("--server")) {
23922
23972
  runMain(main, { showUsage: showUsage2 });
23923
23973
  }
23924
23974
 
23925
- //# debugId=74D4BFCB8CE1AB2064756E2164756E21
23975
+ //# debugId=25AC050A4B6CCBA264756E2164756E21