dsh-grove 0.0.0-stage → 0.1.1
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 +83 -2
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.en.md +37 -0
- package/docs/DETAIL.zh.md +37 -0
- package/icon.svg +8 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +46 -4
- package/src/cli.js +75 -0
- package/src/plugin.js +55 -0
- package/src/proposals.js +59 -0
- package/src/vault.js +180 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wali@2026
|
|
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
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Grove(知林)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Grove 是 DeepSeek Harness 的个人知识成长插件。用 Obsidian vault 保存灵感、问题、知识资产和研究证据。探索是常态,研究是选择,成果自然生长。
|
|
6
|
+
|
|
7
|
+
## 安装 v0.1.1
|
|
8
|
+
|
|
9
|
+
需要 Node.js 22 或更高版本、一个已有的 Obsidian vault,以及 DeepSeek Harness `>=0.2.2-rc.1 <0.3.0`。兼容范围由 `engines.dsh` 声明:它必须是范围而不是某一个精确版本,否则 dsh 会把整个 bundle 判为不兼容并跳过加载。
|
|
10
|
+
|
|
11
|
+
### 装进现有的 Harness
|
|
12
|
+
|
|
13
|
+
| 入口 | 做法 |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Desktop | **Plugins → Add plugin**,选择本仓库目录;按目录安装会装成 `link:`,改完代码重启宿主即生效 |
|
|
16
|
+
| 会话里 | 让 agent 调用 `plugin_manager` 的 `install_bundle`,`target` 传目录绝对路径 |
|
|
17
|
+
| CLI | `dsh plugin --profile <profile> add <目录或 tgz>`。Desktop 的 profile 由 App 管理,命令行装进 `profiles/web` 不会出现在 Desktop |
|
|
18
|
+
|
|
19
|
+
装完在 **Plugins** 页确认 Grove 这一条出现,并在新会话里让模型调用 `grove_role`——工具真的注册上才算装好。
|
|
20
|
+
|
|
21
|
+
### 独立 profile 试用(不碰日常配置)
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
export GROVE_VAULT="/absolute/path/to/Obsidian vault"
|
|
25
|
+
npx --yes @deepseek-ai/dsh@0.2.2-rc.3 plugin --profile grove add "$PWD"
|
|
26
|
+
npx --yes @deepseek-ai/dsh@0.2.2-rc.3 --profile grove
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
独立配置只初始化一次;已有 `grove` 配置时直接安装或启动。用独立配置试用,确保宿主的 Bash、文件编辑、通用自动化和其他扩展无法访问 vault 或代你运行本地确认命令。Grove 自身的模型工具只有角色提示、草稿和白名单查询。普通 Harness 会话仍遵循宿主的模型路由、权限和历史保留设置。
|
|
30
|
+
|
|
31
|
+
### 命令行工具
|
|
32
|
+
|
|
33
|
+
`grove` 命令来自包里的 `bin`,要全局安装才有;把插件装进某个 profile **不会**把它加进 PATH:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm pack --pack-destination /tmp
|
|
37
|
+
npm install --global /tmp/dsh-grove-0.1.1.tgz
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 日常使用
|
|
41
|
+
|
|
42
|
+
1. 终端执行 `grove capture --body '为什么组织扩大后协调成本会上升?'`。在 Obsidian 中打开 vault 的 `Grove` 文件夹可看到 Markdown 笔记。
|
|
43
|
+
2. 在 Harness 中自由对话。可要求使用 `grove_role` 作为思想伙伴、知识助手、研究助手、批判性审查者或创造助手。想保留一段讨论时,请求 `grove_propose` 起草,并提供你愿意保存的会话引用。探索可以直接结束。
|
|
44
|
+
3. 在自己的终端执行 `grove preview 草稿ID`。用 `grove accept 草稿ID` 查看并输入 `SAVE`,或执行 `grove discard 草稿ID`。确认时也可用 `--file` 提供你编辑过的正文。
|
|
45
|
+
4. 用 `grove list` 查询资产,用 `grove show 资产ID --version 1` 回看具体版本。默认 Grove 不向模型提供资产正文。若希望分享指定笔记,启动宿主前将 `GROVE_READABLE_IDS` 设为逗号分隔的资产 ID。取消分享后重新启动宿主。已进入宿主会话的内容仍受宿主历史设置约束。
|
|
46
|
+
|
|
47
|
+
## 基础研究
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
grove add question --body '团队规模如何影响决策效率?'
|
|
51
|
+
grove add research --question QUESTION_ID --body '比较决策权限和协调成本'
|
|
52
|
+
grove add evidence --body '粘贴可核对的摘录' --source 'https://example.org/report' --locator '第12页第2段'
|
|
53
|
+
grove add claim --body '我目前的判断'
|
|
54
|
+
grove update CLAIM_ID --version 1 --link EVIDENCE_ID --relation supports
|
|
55
|
+
grove update RESEARCH_ID --version 1 --link CLAIM_ID --relation related
|
|
56
|
+
grove update RESEARCH_ID --version 2 --stage analysis
|
|
57
|
+
grove update RESEARCH_ID --version 3 --stage review --status closed
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
把示例 ID 替换成上一步返回的 ID。阶段可回退,问题和项目可暂停、重开或归档。证据关系可表达支持、反驳和限定,双链指向具体版本。重写观点正文时,旧证据关系留在原版本,用户可以为新版本重新采用证据。结束研究时,成果可选。
|
|
61
|
+
|
|
62
|
+
## 数据和卸载
|
|
63
|
+
|
|
64
|
+
`Grove/` 内的版本文件使用 YAML 属性、Markdown 正文和 Obsidian 双链。Grove 不读 vault 中其他笔记。复制整个 `Grove/` 文件夹即可备份或迁移,点号开头的草稿文件也会随整目录迁移。可在 Obsidian 阅读及编辑正文,保留文件名和 Grove 元数据。要保留更改前的版本,使用 `grove update`。本版证据使用用户填写的来源标识与定位,附件管理、自动会话定位和专用插件界面留待后续版本。
|
|
65
|
+
|
|
66
|
+
用 `npx --yes @deepseek-ai/dsh@0.2.2-rc.3 plugin --profile grove remove dsh-grove` 卸载宿主插件。数据留在 vault,终端命令可通过 `npm uninstall --global dsh-grove` 单独卸载。
|
|
67
|
+
|
|
68
|
+
## 品牌资产
|
|
69
|
+
|
|
70
|
+
标志、锁定组合、色板、字体与导出图都在 [`brand/`](brand/README.md),规范见 [`brand/VI.md`](brand/VI.md)(中文)与 [`brand/VI.en.md`](brand/VI.en.md)(English)。根目录的 `icon.svg` 与 `brand/mark.svg` 保持字节一致,由测试保证。
|
|
71
|
+
|
|
72
|
+
## 参与开发
|
|
73
|
+
|
|
74
|
+
规则只有一份:[`AGENTS.md`](AGENTS.md)([English](AGENTS.en.md))。Claude Code、Cursor、GitHub Copilot 分别读 `CLAUDE.md`、`.cursor/rules/project.mdc`、`.github/copilot-instructions.md`,三者都只指向 `AGENTS.md`。
|
|
75
|
+
|
|
76
|
+
理念、任务分层、DoD 与评审清单在 [`docs/development/`](docs/development/README.md);会话交接在 [`docs/journal/`](docs/journal/)。改动前先跑 `npm test` 与 `bash scripts/check-compat.sh`。
|
|
77
|
+
|
|
78
|
+
## 从源代码验证
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm ci
|
|
82
|
+
npm test
|
|
83
|
+
npm pack
|
|
84
|
+
```
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Grove
|
|
2
|
+
|
|
3
|
+
Grove turns DeepSeek Harness conversations into versioned knowledge assets in an Obsidian vault: inspirations, questions, concepts, theories, judgements, methods, outputs, explorations and research projects. AI helps you think; **every saved asset is confirmed by you**.
|
|
4
|
+
|
|
5
|
+
## What it provides
|
|
6
|
+
|
|
7
|
+
Three model tools, registered inside the host:
|
|
8
|
+
|
|
9
|
+
| Tool | Purpose | Boundary |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `grove_role` | Returns the collaboration guidance for a role you named explicitly (`thinking` / `knowledge` / `research` / `critical` / `creation`) | Read-only text; never touches the vault |
|
|
12
|
+
| `grove_propose` | Drafts one proposal from your request and writes it as `.proposal.json` | Does not create an asset; only `grove accept` in your terminal saves it |
|
|
13
|
+
| `grove_read` | Reads one asset you explicitly shared | Only IDs listed in `GROVE_READABLE_IDS` |
|
|
14
|
+
|
|
15
|
+
There are ten asset types: inspiration, question, concept, theory, claim, evidence, method, output, exploration and research. Research projects and evidence are recorded through the terminal commands; the model may only draft the other types.
|
|
16
|
+
|
|
17
|
+
## How to use it
|
|
18
|
+
|
|
19
|
+
1. Configure the vault: the plugin config field `vault`, or the `GROVE_VAULT` environment variable. It must be an **absolute path to a real directory**; symlinks in the path are rejected.
|
|
20
|
+
2. Talk to the agent; ask it to call `grove_role` when you want a different way of collaborating.
|
|
21
|
+
3. To keep part of a discussion, ask it to call `grove_propose`, passing the conversation reference you want saved as `origin`.
|
|
22
|
+
4. In your terminal, `grove preview <draft-id>` to review, `grove accept <draft-id>` and type `SAVE` to save, or `grove discard <draft-id>`.
|
|
23
|
+
5. `grove list` / `grove show <asset-id> --version <N>` to look back; `grove update` writes a new version and leaves the old version's evidence links on it.
|
|
24
|
+
|
|
25
|
+
## Where the data lives
|
|
26
|
+
|
|
27
|
+
Everything is under `<vault>/Grove/`: one file per asset, `<id>.v<version>.md`, with YAML frontmatter, a Markdown body and Obsidian wikilinks; dot-prefixed `.proposal.json` files beside them are drafts. There is no database — copying the whole `Grove/` folder is the backup and the migration.
|
|
28
|
+
|
|
29
|
+
## Known limitations
|
|
30
|
+
|
|
31
|
+
- Evidence records only the source identifier and locator you type (`--source` / `--locator`). No attachment management, no automatic session locating.
|
|
32
|
+
- No dedicated Web UI: the surface is the in-session tools plus the terminal CLI.
|
|
33
|
+
- Saving must happen in an interactive terminal (`grove accept` asks you to type `SAVE`); the model cannot save on your behalf.
|
|
34
|
+
- Asset bodies are not given to the model by default; sharing requires `GROVE_READABLE_IDS` in advance, and turning sharing off needs a host restart.
|
|
35
|
+
- One note is capped at 1 MiB and a draft body at 128 KiB; `grove_read` refuses assets outside the sharing list and archived assets.
|
|
36
|
+
- The `grove` command comes from the package's `bin` and needs a global install; installing the plugin into a profile does not put it on `PATH`.
|
|
37
|
+
- Compatible with dsh `>=0.2.2-rc.1 <0.3.0`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Grove(知林)
|
|
2
|
+
|
|
3
|
+
Grove 把 DeepSeek Harness 里的对话,变成 Obsidian vault 里可版本化的知识资产:灵感、问题、概念、理论、判断、方法、成果、探索、研究。AI 协助思考,**保存始终由你确认**。
|
|
4
|
+
|
|
5
|
+
## 提供什么
|
|
6
|
+
|
|
7
|
+
三个模型工具,注册在宿主内:
|
|
8
|
+
|
|
9
|
+
| 工具 | 作用 | 边界 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `grove_role` | 返回你明确点名的协作角色指引(`thinking` / `knowledge` / `research` / `critical` / `creation`) | 只读文本,不碰 vault |
|
|
12
|
+
| `grove_propose` | 按你的要求起草一条草稿,写成 `.proposal.json` | 不创建资产;要你在终端执行 `grove accept` 才落盘 |
|
|
13
|
+
| `grove_read` | 读取一条你显式分享的资产 | 只认 `GROVE_READABLE_IDS` 白名单里的 ID |
|
|
14
|
+
|
|
15
|
+
资产类型共十种:灵感、问题、概念、理论、判断、证据、方法、成果、探索、研究。研究与证据走终端命令,模型只能起草其余类型。
|
|
16
|
+
|
|
17
|
+
## 怎么用
|
|
18
|
+
|
|
19
|
+
1. 配置 vault:插件配置里的 `vault`,或环境变量 `GROVE_VAULT`。必须是**绝对路径的真实目录**;路径里的 symlink 会被拒绝。
|
|
20
|
+
2. 在会话里对话,需要时让模型调用 `grove_role` 切换协作方式。
|
|
21
|
+
3. 想留下讨论时,让模型调用 `grove_propose` 起草,并把你要保存的会话引用作为 `origin` 传入。
|
|
22
|
+
4. 在终端 `grove preview <草稿ID>` 看内容,`grove accept <草稿ID>` 并输入 `SAVE` 落盘,或用 `grove discard` 丢弃。
|
|
23
|
+
5. `grove list` / `grove show <资产ID> --version <N>` 回看;`grove update` 写入新版本,旧版本的证据关系留在旧版本上。
|
|
24
|
+
|
|
25
|
+
## 数据放在哪
|
|
26
|
+
|
|
27
|
+
全在 `<vault>/Grove/`:一条资产一个文件 `<id>.v<版本>.md`,YAML frontmatter + Markdown 正文 + Obsidian 双链;点号开头的 `.proposal.json` 是同目录的草稿。没有数据库,复制整个 `Grove/` 就是备份和迁移。
|
|
28
|
+
|
|
29
|
+
## 已知限制
|
|
30
|
+
|
|
31
|
+
- 证据只记录你填写的来源标识与定位(`--source` / `--locator`),没有附件管理,也不自动定位会话。
|
|
32
|
+
- 没有专用 Web 界面:交互面是会话里的工具加终端 CLI。
|
|
33
|
+
- 保存必须在交互式终端完成(`grove accept` 要手输 `SAVE`),模型不能代你落盘。
|
|
34
|
+
- 默认不把资产正文交给模型;分享要预先设置 `GROVE_READABLE_IDS`,取消分享后需重启宿主。
|
|
35
|
+
- 单条笔记上限 1 MiB,草稿正文上限 128 KiB;`grove_read` 拒绝白名单外和已归档的资产。
|
|
36
|
+
- `grove` 命令来自 npm 全局安装的 `bin`;把插件装进某个 profile 不会把它加进 PATH。
|
|
37
|
+
- 兼容 dsh `>=0.2.2-rc.1 <0.3.0`。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
|
|
2
|
+
<g transform="translate(18 18) scale(1.12) translate(-18.6 -20.9)">
|
|
3
|
+
<path d="M16.9 30.4h2.2l-.65-12.3h-.9z" fill="#1B3A2E"/>
|
|
4
|
+
<path d="M18.4 19c2.2-6.5 8.3-9.4 10.5-8.15-1 5.3-4.9 8.45-10.5 8.15Z" fill="#2F7D5B"/>
|
|
5
|
+
<path d="M17.6 23c-2.2-5.1-6.5-7.3-8.5-6.3.7 4.1 4.3 6.3 8.5 6.3Z" fill="#5FA37E"/>
|
|
6
|
+
<path d="M15.4 31.5h5.2" stroke="#C98A3B" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-grove",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Personal knowledge growth for DeepSeek Harness and Obsidian",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/plugin.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/plugin.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./icon": "./icon.svg"
|
|
13
|
+
},
|
|
14
|
+
"icon": "icon.svg",
|
|
15
|
+
"bin": {
|
|
16
|
+
"grove": "./src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"locale/*.json",
|
|
22
|
+
"icon.svg",
|
|
23
|
+
"docs/DETAIL.en.md",
|
|
24
|
+
"docs/DETAIL.zh.md"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22",
|
|
28
|
+
"dsh": ">=0.2.2-rc.1 <0.3.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"yaml": "2.9.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@deepseek-ai/dsh-tools": ">=0.2.2-rc.1 <0.3.0",
|
|
38
|
+
"semver": "^7.7.0"
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/dsh-tools": ">=0.2.2-rc.1 <0.3.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { createInterface } from 'node:readline/promises'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { Vault, stages, types } from './vault.js'
|
|
6
|
+
import { accept, discard, preview } from './proposals.js'
|
|
7
|
+
|
|
8
|
+
const usage = `Grove v0.1 · set GROVE_VAULT to an absolute Obsidian vault path
|
|
9
|
+
grove capture --body 'idea'
|
|
10
|
+
grove add TYPE --body 'text' [--title 'title'] [--origin 'session or source']
|
|
11
|
+
grove add evidence --body 'excerpt' --source 'source URL or identifier' --locator 'p. 12'
|
|
12
|
+
grove add research --body 'scope' --question QUESTION_ID
|
|
13
|
+
grove list [--query 'term']
|
|
14
|
+
grove show ID [--version N]
|
|
15
|
+
grove update ID --version N [--body 'new text'] [--status open|paused|closed|archived]
|
|
16
|
+
grove update ID --version N --stage STAGE
|
|
17
|
+
grove update ID --version N --link TARGET_ID --relation supports|refutes|qualifies|related|derived
|
|
18
|
+
grove preview DRAFT_ID
|
|
19
|
+
grove discard DRAFT_ID
|
|
20
|
+
grove accept DRAFT_ID [--body 'edited text' | --file FILE]
|
|
21
|
+
Types: ${types.join(', ')}
|
|
22
|
+
Research stages: ${stages.join(', ')}
|
|
23
|
+
Use --file instead of --body for multiline text.
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
function print(value) {
|
|
27
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const { positionals: [command, id, ...extra], values } = parseArgs({
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
options: Object.fromEntries(['body', 'file', 'title', 'origin', 'source', 'locator', 'question', 'query', 'version', 'status', 'stage', 'link', 'relation'].map(name => [name, { type: 'string' }])),
|
|
34
|
+
})
|
|
35
|
+
if (!command || command === 'help') return process.stdout.write(usage)
|
|
36
|
+
if (extra.length) throw new Error('Unexpected positional arguments')
|
|
37
|
+
if (values.body !== undefined && values.file !== undefined) throw new Error('Specify either --body or --file')
|
|
38
|
+
const vault = new Vault(process.env.GROVE_VAULT)
|
|
39
|
+
const body = values.file === undefined ? values.body : await readFile(values.file, 'utf8')
|
|
40
|
+
const version = values.version === undefined ? undefined : Number(values.version)
|
|
41
|
+
switch (command) {
|
|
42
|
+
case 'capture': return print(await vault.create({ ...values, body, type: 'inspiration' }))
|
|
43
|
+
case 'add': return print(await vault.create({ ...values, body, type: id }))
|
|
44
|
+
case 'list': return print((await vault.list(values.query)).map(({ meta }) => meta))
|
|
45
|
+
case 'show': return print(await vault.get(id, version))
|
|
46
|
+
case 'update': {
|
|
47
|
+
if (!Number.isSafeInteger(version) || version < 1) throw new Error('Provide the current --version')
|
|
48
|
+
if (!body && !values.status && !values.stage && !values.link) throw new Error('Provide a change')
|
|
49
|
+
const link = values.link ? { id: values.link, kind: values.relation } : undefined
|
|
50
|
+
return print(await vault.update(id, version, { ...values, body, link }))
|
|
51
|
+
}
|
|
52
|
+
case 'preview': return print(await preview(vault, id))
|
|
53
|
+
case 'discard': return discard(vault, id)
|
|
54
|
+
case 'accept': {
|
|
55
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Acceptance requires an interactive terminal')
|
|
56
|
+
const proposal = await preview(vault, id)
|
|
57
|
+
if (body !== undefined) proposal.body = body
|
|
58
|
+
print(proposal)
|
|
59
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout })
|
|
60
|
+
try {
|
|
61
|
+
if ((await terminal.question('Type SAVE to create this asset: ')) !== 'SAVE') {
|
|
62
|
+
return process.stdout.write('Cancelled. No asset created.\n')
|
|
63
|
+
}
|
|
64
|
+
return print(await accept(vault, id, proposal))
|
|
65
|
+
} finally {
|
|
66
|
+
terminal.close()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
default: throw new Error('Unknown command; run grove help')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
main().catch(error => {
|
|
73
|
+
process.stderr.write(`${error.message}\n`)
|
|
74
|
+
process.exitCode = 1
|
|
75
|
+
})
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { Vault, types } from './vault.js'
|
|
3
|
+
import { propose, roles } from './proposals.js'
|
|
4
|
+
|
|
5
|
+
export const name = 'grove'
|
|
6
|
+
export const inject = ['tools']
|
|
7
|
+
|
|
8
|
+
const output = {
|
|
9
|
+
schema: { type: 'string' },
|
|
10
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function apply(ctx, config = {}) {
|
|
14
|
+
const path = config.vault ?? process.env.GROVE_VAULT
|
|
15
|
+
if (!path) return
|
|
16
|
+
const vault = new Vault(path)
|
|
17
|
+
const allowed = new Set(config.readableIds ?? (process.env.GROVE_READABLE_IDS ?? '').split(',').map(id => id.trim()).filter(Boolean))
|
|
18
|
+
|
|
19
|
+
ctx.tools.register(defineTool({
|
|
20
|
+
name: 'grove_role',
|
|
21
|
+
description: 'Get the Grove collaboration guidance for a role explicitly requested by the user.',
|
|
22
|
+
parameters: { role: { type: 'string', enum: Object.keys(roles), required: true } },
|
|
23
|
+
output,
|
|
24
|
+
execute: ({ role }) => roles[role],
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
ctx.tools.register(defineTool({
|
|
28
|
+
name: 'grove_propose',
|
|
29
|
+
description: 'When asked, save a draft for local human review. This tool does not create a knowledge asset.',
|
|
30
|
+
parameters: {
|
|
31
|
+
type: { type: 'string', enum: types.filter(type => !['evidence', 'research'].includes(type)), required: true },
|
|
32
|
+
body: { type: 'string', required: true },
|
|
33
|
+
title: { type: 'string' },
|
|
34
|
+
origin: { type: 'string', description: 'User-approved session reference or source description' },
|
|
35
|
+
},
|
|
36
|
+
output,
|
|
37
|
+
async execute(args) {
|
|
38
|
+
const id = await propose(vault, args)
|
|
39
|
+
return `Draft ${id}. The user can review it with grove preview ${id} in their terminal.`
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
ctx.tools.register(defineTool({
|
|
44
|
+
name: 'grove_read',
|
|
45
|
+
description: 'Read one explicitly shared Grove asset by ID.',
|
|
46
|
+
parameters: { id: { type: 'string', required: true } },
|
|
47
|
+
output,
|
|
48
|
+
async execute({ id }) {
|
|
49
|
+
if (!allowed.has(id)) return 'This asset is outside the configured Grove sharing scope.'
|
|
50
|
+
const { meta, body } = await vault.get(id)
|
|
51
|
+
if (meta.status === 'archived') return 'This asset is archived.'
|
|
52
|
+
return JSON.stringify({ id, version: meta.version, title: meta.title, type: meta.type, body, source: meta.source, locator: meta.locator })
|
|
53
|
+
},
|
|
54
|
+
}))
|
|
55
|
+
}
|
package/src/proposals.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { open, unlink } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { types } from './vault.js'
|
|
6
|
+
|
|
7
|
+
export const roles = {
|
|
8
|
+
thinking: 'Clarify the question, expose assumptions, and offer alternative explanations.',
|
|
9
|
+
knowledge: 'Explain concepts, compare examples, and distinguish the user’s understanding from references.',
|
|
10
|
+
research: 'Identify research questions, methods, evidence gaps, and traceable sources.',
|
|
11
|
+
critical: 'Seek counterexamples and alternative explanations. Separate evidence from speculation.',
|
|
12
|
+
creation: 'Help structure the user’s argument and preserve source references. Produce a draft only on request.',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function draftFile(vault, id, write = false) {
|
|
16
|
+
if (typeof id !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(id)) throw new Error('Invalid proposal ID')
|
|
17
|
+
const directory = await vault.folder(write)
|
|
18
|
+
const flags = write ? constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
19
|
+
: constants.O_RDONLY | constants.O_NONBLOCK
|
|
20
|
+
return open(join(directory, `.${id}.proposal.json`), flags | constants.O_NOFOLLOW, 0o600)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function propose(vault, input) {
|
|
24
|
+
const { type, title, body, origin } = input
|
|
25
|
+
if (!types.includes(type) || ['research', 'evidence'].includes(type)) throw new Error('Unsupported proposal type')
|
|
26
|
+
if (typeof body !== 'string' || !body.trim() || Buffer.byteLength(body) > 128 * 1024) throw new Error('Invalid proposal body')
|
|
27
|
+
if (title && (typeof title !== 'string' || title.length > 300)) throw new Error('Invalid proposal title')
|
|
28
|
+
if (origin && (typeof origin !== 'string' || origin.length > 2000)) throw new Error('Invalid proposal origin')
|
|
29
|
+
const id = randomUUID()
|
|
30
|
+
const handle = await draftFile(vault, id, true)
|
|
31
|
+
try {
|
|
32
|
+
await handle.writeFile(JSON.stringify({ type, title, body, origin }))
|
|
33
|
+
} finally {
|
|
34
|
+
await handle.close()
|
|
35
|
+
}
|
|
36
|
+
return id
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function preview(vault, id) {
|
|
40
|
+
const handle = await draftFile(vault, id)
|
|
41
|
+
try {
|
|
42
|
+
const stat = await handle.stat()
|
|
43
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('Invalid proposal file')
|
|
44
|
+
return JSON.parse(await handle.readFile('utf8'))
|
|
45
|
+
} finally {
|
|
46
|
+
await handle.close()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function accept(vault, id, proposal) {
|
|
51
|
+
const note = await vault.create({ ...proposal, origin: proposal.origin ? `AI draft confirmed by user · ${proposal.origin}` : 'AI draft confirmed by user' }, id)
|
|
52
|
+
await discard(vault, id).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
53
|
+
return note
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function discard(vault, id) {
|
|
57
|
+
await preview(vault, id)
|
|
58
|
+
await unlink(join(await vault.folder(), `.${id}.proposal.json`))
|
|
59
|
+
}
|
package/src/vault.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, realpath, mkdir, open, link, unlink, readdir } from 'node:fs/promises'
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
|
+
import { parse, stringify } from 'yaml'
|
|
6
|
+
|
|
7
|
+
export const types = ['inspiration', 'question', 'concept', 'theory', 'claim', 'evidence', 'method', 'output', 'exploration', 'research']
|
|
8
|
+
export const stages = ['question', 'framework', 'evidence', 'analysis', 'judgment', 'validation', 'expression', 'review']
|
|
9
|
+
const relations = ['related', 'derived', 'supports', 'refutes', 'qualifies']
|
|
10
|
+
const validId = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/
|
|
11
|
+
const filename = /^([a-f0-9-]{36})\.v([1-9][0-9]*)\.md$/
|
|
12
|
+
const maxBytes = 1024 * 1024
|
|
13
|
+
|
|
14
|
+
function required(value, label) {
|
|
15
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} is required`)
|
|
16
|
+
return value.trim()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function directory(path, create) {
|
|
20
|
+
if (create) await mkdir(path).catch(error => { if (error.code !== 'EEXIST') throw error })
|
|
21
|
+
const stat = await lstat(path)
|
|
22
|
+
if (!stat.isDirectory() || await realpath(path) !== path) throw new Error('Grove requires a real directory')
|
|
23
|
+
return path
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encode(meta, body) {
|
|
27
|
+
const links = [
|
|
28
|
+
...(meta.question ? [`[[${meta.question}.v${meta.questionVersion ?? 1}]]`] : []),
|
|
29
|
+
...(meta.links ?? []).map(item => `${item.kind} [[${item.id}.v${item.version}]]`),
|
|
30
|
+
]
|
|
31
|
+
return `---\n${stringify(meta, { lineWidth: 0 })}---\n\n${body}\n${links.length ? `\n<!-- grove-links -->\n${links.join('\n')}\n` : ''}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readNote(path, id, version) {
|
|
35
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
36
|
+
try {
|
|
37
|
+
const stat = await handle.stat()
|
|
38
|
+
if (!stat.isFile() || stat.size > maxBytes) throw new Error('Invalid Grove note file')
|
|
39
|
+
const raw = await handle.readFile('utf8')
|
|
40
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw)
|
|
41
|
+
if (!match) throw new Error('Invalid Grove Markdown frontmatter')
|
|
42
|
+
const meta = parse(match[1], { maxAliasCount: 0 })
|
|
43
|
+
if (!meta || meta.id !== id || meta.version !== version || !types.includes(meta.type)) {
|
|
44
|
+
throw new Error('Grove note metadata does not match its filename')
|
|
45
|
+
}
|
|
46
|
+
return { meta, body: match[2].split('\n<!-- grove-links -->\n')[0].trim() }
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function publish(path, target, content) {
|
|
53
|
+
if (Buffer.byteLength(content) > maxBytes) throw new Error('Grove note exceeds the size limit')
|
|
54
|
+
const temp = join(path, `.${randomUUID()}.tmp`)
|
|
55
|
+
const handle = await open(temp, 'wx', 0o600)
|
|
56
|
+
try {
|
|
57
|
+
try {
|
|
58
|
+
await handle.writeFile(content)
|
|
59
|
+
await handle.sync()
|
|
60
|
+
} finally {
|
|
61
|
+
await handle.close()
|
|
62
|
+
}
|
|
63
|
+
await link(temp, target)
|
|
64
|
+
} finally {
|
|
65
|
+
await unlink(temp)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Vault {
|
|
70
|
+
constructor(path) {
|
|
71
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('Set GROVE_VAULT to an absolute vault path')
|
|
72
|
+
this.path = path
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async folder(create = false) {
|
|
76
|
+
return directory(join(await realpath(this.path), 'Grove'), create)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async inventory() {
|
|
80
|
+
const path = await this.folder().catch(error => {
|
|
81
|
+
if (error.code === 'ENOENT') return null
|
|
82
|
+
throw error
|
|
83
|
+
})
|
|
84
|
+
const entries = new Map()
|
|
85
|
+
for (const name of path ? await readdir(path) : []) {
|
|
86
|
+
const match = filename.exec(name)
|
|
87
|
+
if (!match || !validId.test(match[1])) continue
|
|
88
|
+
const version = Number(match[2])
|
|
89
|
+
if (!Number.isSafeInteger(version)) continue
|
|
90
|
+
const versions = entries.get(match[1]) ?? []
|
|
91
|
+
versions.push(version)
|
|
92
|
+
entries.set(match[1], versions)
|
|
93
|
+
}
|
|
94
|
+
return entries
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async get(id, version) {
|
|
98
|
+
if (typeof id !== 'string' || !validId.test(id)) throw new Error('Invalid Grove ID')
|
|
99
|
+
const selected = version ?? Math.max(...((await this.inventory()).get(id) ?? []))
|
|
100
|
+
if (!Number.isSafeInteger(selected) || selected < 1) throw new Error('Grove note not found')
|
|
101
|
+
return readNote(join(await this.folder(), `${id}.v${selected}.md`), id, selected)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async list(query = '') {
|
|
105
|
+
const entries = await this.inventory()
|
|
106
|
+
const notes = await Promise.all([...entries].map(([id, versions]) => this.get(id, Math.max(...versions))))
|
|
107
|
+
const needle = query.toLowerCase()
|
|
108
|
+
return notes.filter(({meta, body}) => `${meta.title}\n${meta.type}\n${body}`.toLowerCase().includes(needle))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async create(input, id = randomUUID()) {
|
|
112
|
+
if (!validId.test(id)) throw new Error('Invalid Grove ID')
|
|
113
|
+
const type = input.type
|
|
114
|
+
if (!types.includes(type)) throw new Error('Unknown asset type')
|
|
115
|
+
const body = required(input.body, 'body')
|
|
116
|
+
const meta = { grove: 1, id, version: 1, type, title: input.title?.trim() || body.split('\n')[0].slice(0, 100), created: new Date().toISOString() }
|
|
117
|
+
if (input.origin) meta.origin = required(input.origin, 'origin')
|
|
118
|
+
if (type === 'question' || type === 'research') meta.status = 'open'
|
|
119
|
+
if (type === 'research') {
|
|
120
|
+
const question = await this.get(input.question)
|
|
121
|
+
if (question.meta.type !== 'question') throw new Error('Research requires a question')
|
|
122
|
+
meta.question = question.meta.id
|
|
123
|
+
meta.questionVersion = question.meta.version
|
|
124
|
+
meta.stage = stages[0]
|
|
125
|
+
}
|
|
126
|
+
if (type === 'evidence') {
|
|
127
|
+
meta.source = required(input.source, 'source')
|
|
128
|
+
meta.locator = required(input.locator, 'locator')
|
|
129
|
+
}
|
|
130
|
+
const path = await this.folder(true)
|
|
131
|
+
await publish(path, join(path, `${id}.v1.md`), encode(meta, body))
|
|
132
|
+
return { meta, body }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async update(id, expected, change) {
|
|
136
|
+
const note = await this.get(id)
|
|
137
|
+
if (note.meta.version !== expected) throw new Error('Version conflict; read the latest note and retry')
|
|
138
|
+
const { meta } = note
|
|
139
|
+
let body = note.body
|
|
140
|
+
if (change.body !== undefined) {
|
|
141
|
+
body = required(change.body, 'body')
|
|
142
|
+
if (meta.type === 'claim' && body !== note.body) {
|
|
143
|
+
meta.links = (meta.links ?? []).filter(edge => !['supports', 'refutes', 'qualifies'].includes(edge.kind))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (change.status !== undefined) {
|
|
147
|
+
const allowed = meta.type === 'question' || meta.type === 'research'
|
|
148
|
+
? ['open', 'paused', 'closed', 'archived'] : ['active', 'archived']
|
|
149
|
+
if (!allowed.includes(change.status)) throw new Error('Invalid status for this asset')
|
|
150
|
+
meta.status = change.status
|
|
151
|
+
}
|
|
152
|
+
if (change.stage !== undefined) {
|
|
153
|
+
if (meta.type !== 'research' || !stages.includes(change.stage)) throw new Error('Invalid research stage')
|
|
154
|
+
meta.stage = change.stage
|
|
155
|
+
}
|
|
156
|
+
if (change.link) {
|
|
157
|
+
const { kind, id: targetId, version } = change.link
|
|
158
|
+
if (!relations.includes(kind)) throw new Error('Invalid relationship')
|
|
159
|
+
const target = await this.get(targetId, version)
|
|
160
|
+
if (targetId === id) throw new Error('An asset cannot link to itself')
|
|
161
|
+
if (['supports', 'refutes', 'qualifies'].includes(kind) && (meta.type !== 'claim' || target.meta.type !== 'evidence')) {
|
|
162
|
+
throw new Error('Evidence relations must run from a claim to evidence')
|
|
163
|
+
}
|
|
164
|
+
const edge = { kind, id: targetId, version: target.meta.version }
|
|
165
|
+
if (!(meta.links ?? []).some(link => link.kind === kind && link.id === targetId && link.version === edge.version)) {
|
|
166
|
+
meta.links = [...(meta.links ?? []), edge]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
meta.version++
|
|
170
|
+
meta.modified = new Date().toISOString()
|
|
171
|
+
const path = await this.folder()
|
|
172
|
+
try {
|
|
173
|
+
await publish(path, join(path, `${id}.v${meta.version}.md`), encode(meta, body))
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.code === 'EEXIST') throw new Error('Version conflict; read the latest note and retry')
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
return { meta, body }
|
|
179
|
+
}
|
|
180
|
+
}
|