dsh-grove 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +103 -0
- package/README.md +28 -7
- package/client.js +639 -0
- package/docs/DETAIL.en.md +2 -36
- package/docs/DETAIL.zh.md +2 -36
- package/docs/preview.svg +46 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +15 -4
- package/src/plugin.js +24 -3
- package/src/server.js +180 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Grove
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[简体中文](README.md) · [English](README.en.md)
|
|
6
|
+
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
[](package.json)
|
|
10
|
+
|
|
11
|
+
## Personal knowledge and growth
|
|
12
|
+
|
|
13
|
+
**PKGS (Personal Knowledge & Growth System) is a method for long-term personal growth.** It connects exploration, learning, evidence-based research, creation, and reflection. Something you understood years ago may help explain a problem today. Evidence you find tomorrow may change a judgment you make today. Over time, you build knowledge you can draw on again and become better at understanding, judging, researching, and communicating.
|
|
14
|
+
|
|
15
|
+
You can practice the method with paper, Markdown, or tools you already use. The [PKGS guide](docs/PKGS.en.md) explains the design and shows how to develop questions, connect ideas, conduct research, track changes in your views, and share the work with AI.
|
|
16
|
+
|
|
17
|
+
- **Exploration** starts with an observation, uncertainty, or association. You can stop anytime or leave an open question to return to later. You decide when to research it and whether to make something from it.
|
|
18
|
+
- **Knowledge growth** starts by explaining an idea in your own words. Connect concepts, examples, and questions, then test your understanding through use and review.
|
|
19
|
+
- **Research and creation** start with a question worth investigating. Preserve sources, counterexamples, and versions of your judgment. When useful, turn what you learned into an article, report, or proposal. Reflection can give you a starting point for the next exploration.
|
|
20
|
+
|
|
21
|
+
AI can ask questions, compare explanations, surface counterexamples, and help you express an idea. You decide what to believe, keep, and share.
|
|
22
|
+
|
|
23
|
+
## How Grove puts the method into practice
|
|
24
|
+
|
|
25
|
+
Grove connects DeepSeek Harness and Obsidian. The current version connects the Grove panel in the left sidebar, open-ended conversation, user-confirmed saves, and basic evidence tracking. After installing, Grove appears in the sidebar: connect a vault and capture a first idea, then return to the conversation. Choose what to keep from a conversation, save it as Markdown in your own vault, and use versions and wikilinks to revisit relationships among questions, evidence, and claims. You can practice the guide’s learning, reflection, and full research methods by hand.
|
|
26
|
+
|
|
27
|
+
## Install v0.2.0
|
|
28
|
+
|
|
29
|
+
You need Node.js 22 or later, an existing Obsidian vault, and DeepSeek Harness `>=0.2.2-rc.1 <0.3.0`. The `engines.dsh` range determines whether the host loads the bundle.
|
|
30
|
+
|
|
31
|
+
### Install in an existing Harness
|
|
32
|
+
|
|
33
|
+
| Entry point | Steps |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Desktop | Open **Plugins → Add plugin** and select this repository directory. The host installs a `link:` dependency. Restart the host after editing the source. |
|
|
36
|
+
| In a session | Ask the agent to call `plugin_manager` with `install_bundle` and pass the directory’s absolute path as `target`. |
|
|
37
|
+
| npm | `dsh plugin --profile <profile> add dsh-grove` (published to npm since 0.2.0). |
|
|
38
|
+
| CLI | Run `dsh plugin --profile <profile> add <directory-or-tgz>`. The Desktop app manages its own profile. Packages installed into `profiles/web` do not appear there. |
|
|
39
|
+
|
|
40
|
+
After installing, **Grove** appears in the left sidebar: open it, choose a vault, and capture a first idea. Check that Grove also appears on the **Plugins** page, then ask the model to call `grove_role` in a new session to confirm the tool is registered.
|
|
41
|
+
|
|
42
|
+
### Try a separate profile
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
export GROVE_VAULT="/absolute/path/to/Obsidian vault"
|
|
46
|
+
npx --yes @deepseek-ai/dsh@0.2.2-rc.3 plugin --profile grove add "$PWD"
|
|
47
|
+
npx --yes @deepseek-ai/dsh@0.2.2-rc.3 --profile grove
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Initialize a separate profile once. If the `grove` profile already exists, install or launch directly. In your trial profile, keep host shell access, file editing, general automation, and other extensions from accessing the vault or running confirmation commands on your behalf. Grove’s model tools provide role guidance, drafts, and allowlisted reads. Harness sessions also follow the host’s routing, permissions, and history settings.
|
|
51
|
+
|
|
52
|
+
### Install the command-line tool
|
|
53
|
+
|
|
54
|
+
A global installation puts the package’s `grove` executable on PATH. Installing the plugin in a profile registers it with the host.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm pack --pack-destination /tmp
|
|
58
|
+
npm install --global /tmp/dsh-grove-0.2.0.tgz
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Everyday use
|
|
62
|
+
|
|
63
|
+
1. Open **Grove** in the left sidebar, choose your Obsidian vault, and capture a first idea. You can also keep using the terminal: `grove capture --body 'Why might coordination costs increase as an organization grows?'`. Open the `Grove` folder in Obsidian to see the Markdown note.
|
|
64
|
+
2. Explore in Harness. Use `grove_role` for a thinking partner, knowledge assistant, research assistant, critical reviewer, or creation assistant. Ask `grove_propose` to draft a note when you want to keep something, and supply the conversation reference you want to save. You can also end the exploration there.
|
|
65
|
+
3. Run `grove preview DRAFT_ID` in your terminal. Use `grove accept DRAFT_ID` and type `SAVE`, or run `grove discard DRAFT_ID`. Pass `--file` when accepting to supply your edited body.
|
|
66
|
+
4. Use `grove list` and `grove show ASSET_ID --version 1` to revisit your notes. To share selected notes with the model, set `GROVE_READABLE_IDS` to a comma-separated list of asset IDs before starting the host. Restart the host after revoking that access. Content already shared in a session follows the host’s history settings.
|
|
67
|
+
|
|
68
|
+
## Basic research
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
grove add question --body 'How does team size affect decision speed?'
|
|
72
|
+
grove add research --question QUESTION_ID --body 'Compare decision authority and coordination costs'
|
|
73
|
+
grove add evidence --body 'Add a checkable excerpt' --source 'https://example.org/report' --locator 'Page 12, paragraph 2'
|
|
74
|
+
grove add claim --body 'My current judgment'
|
|
75
|
+
grove update CLAIM_ID --version 1 --link EVIDENCE_ID --relation supports
|
|
76
|
+
grove update RESEARCH_ID --version 1 --link CLAIM_ID --relation related
|
|
77
|
+
grove update RESEARCH_ID --version 2 --stage analysis
|
|
78
|
+
grove update RESEARCH_ID --version 3 --stage review --status closed
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use the IDs returned by the earlier commands. You can revisit stages and pause, reopen, or archive questions and projects. Evidence can support, refute, or qualify a claim. Links target specific versions. When you rewrite a claim, its earlier evidence links stay on the earlier version. You can explicitly adopt evidence again for the new version. A research project can close with or without a deliverable.
|
|
82
|
+
|
|
83
|
+
## Data and removal
|
|
84
|
+
|
|
85
|
+
Version files in `Grove/` use YAML properties, Markdown bodies, and Obsidian wikilinks. Grove reads within its own folder. Copy the whole folder, including hidden drafts, to back it up or move it. You can read and edit bodies in Obsidian while preserving file names and Grove metadata. Use `grove update` to retain the earlier version. The current version records source identifiers and locations you supply. See the [plugin details](docs/DETAIL.en.md) for current limits.
|
|
86
|
+
|
|
87
|
+
To remove the host plugin, run `npx --yes @deepseek-ai/dsh@0.2.2-rc.3 plugin --profile grove remove dsh-grove`. Your vault data stays in place. Remove the command separately with `npm uninstall --global dsh-grove`.
|
|
88
|
+
|
|
89
|
+
## Brand assets
|
|
90
|
+
|
|
91
|
+
See the [brand assets](brand/README.en.md) and the visual identity guide ([English](brand/VI.en.md) · [简体中文](brand/VI.md)). Tests check that the package’s `icon.svg` matches `brand/mark.svg` byte for byte.
|
|
92
|
+
|
|
93
|
+
## Contributing
|
|
94
|
+
|
|
95
|
+
Read the [development contract](AGENTS.en.md) ([简体中文](AGENTS.md)). The Claude Code, Cursor, and Copilot instructions point to the same contract. See the [development guide](docs/development/README.md) for issue structure, acceptance, and review rules, and the [journal](docs/journal/) for handoffs. Before making changes, run the tests and `bash scripts/check-compat.sh`.
|
|
96
|
+
|
|
97
|
+
## Verify from source
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npm ci
|
|
101
|
+
npm test
|
|
102
|
+
npm pack
|
|
103
|
+
```
|
package/README.md
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
# Grove(知林)
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[简体中文](README.md) · [English](README.en.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
[](package.json)
|
|
10
|
+
|
|
11
|
+
## 个人知识与认知成长
|
|
12
|
+
|
|
13
|
+
**PKGS(Personal Knowledge & Growth System)是一套面向个人长期成长的方法。** 它把思想探索、知识学习、证据研究、创造和反思接起来。过去读懂的东西可以解释今天遇到的问题,今天形成的判断也可以因明天发现的证据而改变。长期积累的是能重新调用的知识,以及理解、判断、研究和表达的能力。
|
|
14
|
+
|
|
15
|
+
这套方法可以用纸笔、Markdown 或自己熟悉的工具实践。[PKGS 方法指南](docs/PKGS.zh.md)从设计依据讲到日常操作,包括如何孵化问题、建立知识关联、开展研究、记录观点的变化,以及怎样与 AI 分工。
|
|
16
|
+
|
|
17
|
+
- **思想探索**从一个观察、困惑或联想开始。你可以随时停下,也可以留一个开放问题以后接着想。研究和产出都由你选择。
|
|
18
|
+
- **知识成长**从用自己的话解释开始。把概念、例子和疑问连起来,在应用和回顾中检验理解。
|
|
19
|
+
- **研究与创造**从值得核查的问题开始。保留来源、反例和判断的不同版本,需要时把形成的认识写成文章、报告或方案。复盘所得可以成为下一轮探索的起点。
|
|
20
|
+
|
|
21
|
+
AI 可以追问、比较解释、查找反例和协助表达。你决定相信什么、留下什么以及和谁分享。
|
|
22
|
+
|
|
23
|
+
## Grove 如何实践这套方法
|
|
24
|
+
|
|
25
|
+
Grove 是连接 DeepSeek Harness 和 Obsidian 的插件。当前版本把左侧 Grove 面板、自由对话、自主保存和基础证据研究接在一起。安装后左侧菜单出现 Grove:连接 vault、写下第一条灵感;回到会话里让 AI 陪你追问。你可以从会话挑出值得保留的内容,把它保存成自己 vault 里的 Markdown,并用版本和双链回看问题、证据与判断的关联。指南里的学习、反思和完整研究方法可以手工实践。
|
|
26
|
+
|
|
27
|
+
## 安装 v0.2.0
|
|
8
28
|
|
|
9
29
|
需要 Node.js 22 或更高版本、一个已有的 Obsidian vault,以及 DeepSeek Harness `>=0.2.2-rc.1 <0.3.0`。兼容范围由 `engines.dsh` 声明:它必须是范围而不是某一个精确版本,否则 dsh 会把整个 bundle 判为不兼容并跳过加载。
|
|
10
30
|
|
|
@@ -14,9 +34,10 @@ Grove 是 DeepSeek Harness 的个人知识成长插件。用 Obsidian vault 保
|
|
|
14
34
|
| --- | --- |
|
|
15
35
|
| Desktop | **Plugins → Add plugin**,选择本仓库目录;按目录安装会装成 `link:`,改完代码重启宿主即生效 |
|
|
16
36
|
| 会话里 | 让 agent 调用 `plugin_manager` 的 `install_bundle`,`target` 传目录绝对路径 |
|
|
37
|
+
| npm | `dsh plugin --profile <profile> add dsh-grove`(0.2.0 起已发布到 npm) |
|
|
17
38
|
| CLI | `dsh plugin --profile <profile> add <目录或 tgz>`。Desktop 的 profile 由 App 管理,命令行装进 `profiles/web` 不会出现在 Desktop |
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
装完左侧菜单出现 **Grove**:点开选择 vault,写下第一条灵感。再到 **Plugins** 页确认 Grove 这一条出现,并在新会话里让模型调用 `grove_role`——工具真的注册上才算装好。
|
|
20
41
|
|
|
21
42
|
### 独立 profile 试用(不碰日常配置)
|
|
22
43
|
|
|
@@ -34,12 +55,12 @@ npx --yes @deepseek-ai/dsh@0.2.2-rc.3 --profile grove
|
|
|
34
55
|
|
|
35
56
|
```sh
|
|
36
57
|
npm pack --pack-destination /tmp
|
|
37
|
-
npm install --global /tmp/dsh-grove-0.
|
|
58
|
+
npm install --global /tmp/dsh-grove-0.2.0.tgz
|
|
38
59
|
```
|
|
39
60
|
|
|
40
61
|
## 日常使用
|
|
41
62
|
|
|
42
|
-
1.
|
|
63
|
+
1. 点开左侧菜单的 **Grove**,选择你的 Obsidian vault,写下第一条灵感。也可以继续用终端:`grove capture --body '为什么组织扩大后协调成本会上升?'`。在 Obsidian 中打开 vault 的 `Grove` 文件夹可看到 Markdown 笔记。
|
|
43
64
|
2. 在 Harness 中自由对话。可要求使用 `grove_role` 作为思想伙伴、知识助手、研究助手、批判性审查者或创造助手。想保留一段讨论时,请求 `grove_propose` 起草,并提供你愿意保存的会话引用。探索可以直接结束。
|
|
44
65
|
3. 在自己的终端执行 `grove preview 草稿ID`。用 `grove accept 草稿ID` 查看并输入 `SAVE`,或执行 `grove discard 草稿ID`。确认时也可用 `--file` 提供你编辑过的正文。
|
|
45
66
|
4. 用 `grove list` 查询资产,用 `grove show 资产ID --version 1` 回看具体版本。默认 Grove 不向模型提供资产正文。若希望分享指定笔记,启动宿主前将 `GROVE_READABLE_IDS` 设为逗号分隔的资产 ID。取消分享后重新启动宿主。已进入宿主会话的内容仍受宿主历史设置约束。
|
|
@@ -61,7 +82,7 @@ grove update RESEARCH_ID --version 3 --stage review --status closed
|
|
|
61
82
|
|
|
62
83
|
## 数据和卸载
|
|
63
84
|
|
|
64
|
-
`Grove/` 内的版本文件使用 YAML 属性、Markdown 正文和 Obsidian 双链。Grove 不读 vault 中其他笔记。复制整个 `Grove/` 文件夹即可备份或迁移,点号开头的草稿文件也会随整目录迁移。可在 Obsidian 阅读及编辑正文,保留文件名和 Grove 元数据。要保留更改前的版本,使用 `grove update
|
|
85
|
+
`Grove/` 内的版本文件使用 YAML 属性、Markdown 正文和 Obsidian 双链。Grove 不读 vault 中其他笔记。复制整个 `Grove/` 文件夹即可备份或迁移,点号开头的草稿文件也会随整目录迁移。可在 Obsidian 阅读及编辑正文,保留文件名和 Grove 元数据。要保留更改前的版本,使用 `grove update`。本版证据使用用户填写的来源标识与定位。当前能力的边界见[插件详情](docs/DETAIL.zh.md)。
|
|
65
86
|
|
|
66
87
|
用 `npx --yes @deepseek-ai/dsh@0.2.2-rc.3 plugin --profile grove remove dsh-grove` 卸载宿主插件。数据留在 vault,终端命令可通过 `npm uninstall --global dsh-grove` 单独卸载。
|
|
67
88
|
|
package/client.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grove client half: the left-sidebar panel and its cold-start page.
|
|
3
|
+
*
|
|
4
|
+
* Single file, plain JavaScript, no build step. Only `require('react')` plus browser APIs.
|
|
5
|
+
* All data comes from the host half's same-origin routes:
|
|
6
|
+
* GET /api/grove/status
|
|
7
|
+
* POST /api/grove/config
|
|
8
|
+
* POST /api/grove/capture
|
|
9
|
+
* Styles use `--dsw-alias-*` tokens only.
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-grove',
|
|
13
|
+
factory: function (require) {
|
|
14
|
+
var React = require('react')
|
|
15
|
+
var h = React.createElement
|
|
16
|
+
var useState = React.useState
|
|
17
|
+
var useEffect = React.useEffect
|
|
18
|
+
var useRef = React.useRef
|
|
19
|
+
|
|
20
|
+
var NS = 'grove'
|
|
21
|
+
var STATUS = '/api/grove/status'
|
|
22
|
+
var CONFIG = '/api/grove/config'
|
|
23
|
+
var CAPTURE = '/api/grove/capture'
|
|
24
|
+
|
|
25
|
+
var ZH = {
|
|
26
|
+
panel: 'Grove',
|
|
27
|
+
menu: '知林',
|
|
28
|
+
subtitle: '把探索留在自己的 vault 里。',
|
|
29
|
+
'status.connected': '已连接',
|
|
30
|
+
'status.disconnected': '未连接',
|
|
31
|
+
'status.readonly': '只读',
|
|
32
|
+
'status.checking': '正在读取…',
|
|
33
|
+
's0.title': '先连接你的 Obsidian vault',
|
|
34
|
+
's0.body': 'Grove 只读写 vault 里的 Grove/ 文件夹,不读其他笔记。连接后,Harness 里的对话就能落成可回看、可修订的 Markdown。',
|
|
35
|
+
's0.step1': '连接 vault',
|
|
36
|
+
's0.step2': '写下第一条灵感',
|
|
37
|
+
's0.step3': '回到会话继续探索',
|
|
38
|
+
's0.pick': '选择文件夹',
|
|
39
|
+
's0.manual': '手动输入路径',
|
|
40
|
+
's0.placeholder': '/Users/你/Obsidian Vault',
|
|
41
|
+
's0.connect': '连接',
|
|
42
|
+
's0.cancel': '取消',
|
|
43
|
+
's0.hint': '路径会保存到当前 profile 的插件配置;之后可在插件页里修改。',
|
|
44
|
+
's0.stale': '原来的 vault 已不可用:{path}。请重新选择,或把它移回原位。',
|
|
45
|
+
's0.noVault': '这个文件夹里没有 .obsidian。如果它不是 vault 根目录,仍可继续连接。',
|
|
46
|
+
's1.connectedTo': '已连接',
|
|
47
|
+
's1.change': '更换 vault',
|
|
48
|
+
's1.title': '写下第一条灵感',
|
|
49
|
+
's1.body': '一个观察、一个困惑、一句还没想清楚的话都可以。保存后它会出现在 vault 的 Grove/ 文件夹里。',
|
|
50
|
+
's1.placeholder': '比如:为什么组织扩大后,协调成本会上升?',
|
|
51
|
+
's1.save': '保存到 vault',
|
|
52
|
+
's1.saving': '正在保存…',
|
|
53
|
+
's1.saved': '已保存',
|
|
54
|
+
's1.savedBody': '已写入 vault。用 Obsidian 打开 Grove/ 文件夹就能看到。回到会话里,让 AI 用 grove_role 陪你继续想。',
|
|
55
|
+
's1.view': '查看概览',
|
|
56
|
+
's1.another': '另一种开始',
|
|
57
|
+
's1.prompt': '用 grove_role 当我的思想伙伴,先帮我拆开这个问题:<你的问题>',
|
|
58
|
+
's1.copy': '复制提示词',
|
|
59
|
+
's1.copied': '已复制',
|
|
60
|
+
's2.title': '你的知识库',
|
|
61
|
+
's2.total': '条资产',
|
|
62
|
+
's2.recent': '最近更新',
|
|
63
|
+
's2.empty': '还没有笔记。',
|
|
64
|
+
's2.continue': '在会话里继续探索',
|
|
65
|
+
's2.prompt': '用 grove_role 当我的思想伙伴,接着上次的问题继续。',
|
|
66
|
+
's2.count.inspiration': '灵感',
|
|
67
|
+
's2.count.question': '问题',
|
|
68
|
+
's2.count.research': '研究',
|
|
69
|
+
's2.count.claim': '判断',
|
|
70
|
+
's2.count.evidence': '证据',
|
|
71
|
+
'type.inspiration': '灵感',
|
|
72
|
+
'type.question': '问题',
|
|
73
|
+
'type.concept': '概念',
|
|
74
|
+
'type.theory': '理论',
|
|
75
|
+
'type.claim': '判断',
|
|
76
|
+
'type.evidence': '证据',
|
|
77
|
+
'type.method': '方法',
|
|
78
|
+
'type.output': '成果',
|
|
79
|
+
'type.exploration': '探索',
|
|
80
|
+
'type.research': '研究',
|
|
81
|
+
'foot.privacy': 'Grove 只读写 vault 的 Grove/ 文件夹,不读其他笔记。',
|
|
82
|
+
'foot.confirm': 'AI 起草的草稿要你在终端确认后才落盘。',
|
|
83
|
+
'foot.data': '数据是你的 Markdown,随时可以备份或迁移。',
|
|
84
|
+
'err.invalid-path': '路径无效,请填写绝对路径。',
|
|
85
|
+
'err.not-directory': '这个路径不是文件夹。',
|
|
86
|
+
'err.symlink': '这个路径是符号链接,请选择真实目录。',
|
|
87
|
+
'err.save-unavailable': '当前 profile 无法保存插件配置,请手动设置 GROVE_VAULT。',
|
|
88
|
+
'err.not-configured': '还没有连接 vault。',
|
|
89
|
+
'err.invalid-body': '内容不能为空。',
|
|
90
|
+
'err.too-large': '内容太长了,请控制在 128 KiB 以内。',
|
|
91
|
+
'err.network': '连不上面板服务,请重启 DSH 后重试。',
|
|
92
|
+
'err.timeout': '配置已提交,但还没生效。请重启 DSH 后再看。',
|
|
93
|
+
'err.internal': '操作失败,请重试。',
|
|
94
|
+
'err.retry': '重试',
|
|
95
|
+
'err.clipboard': '复制被拦截,请手动选择文本。',
|
|
96
|
+
'loading': '正在读取 vault…',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var EN = {
|
|
100
|
+
panel: 'Grove',
|
|
101
|
+
menu: 'Grove',
|
|
102
|
+
subtitle: 'Keep what you explore in your own vault.',
|
|
103
|
+
'status.connected': 'Connected',
|
|
104
|
+
'status.disconnected': 'Not connected',
|
|
105
|
+
'status.readonly': 'Read only',
|
|
106
|
+
'status.checking': 'Reading…',
|
|
107
|
+
's0.title': 'Connect your Obsidian vault',
|
|
108
|
+
's0.body': 'Grove reads and writes only the Grove/ folder inside your vault, never other notes. Once connected, conversations in Harness can become Markdown you can revisit and revise.',
|
|
109
|
+
's0.step1': 'Connect a vault',
|
|
110
|
+
's0.step2': 'Capture a first idea',
|
|
111
|
+
's0.step3': 'Go back to the conversation',
|
|
112
|
+
's0.pick': 'Choose folder',
|
|
113
|
+
's0.manual': 'Enter a path',
|
|
114
|
+
's0.placeholder': '/Users/you/Obsidian Vault',
|
|
115
|
+
's0.connect': 'Connect',
|
|
116
|
+
's0.cancel': 'Cancel',
|
|
117
|
+
's0.hint': 'The path is saved in this profile’s plugin configuration and can be changed later from the Plugins page.',
|
|
118
|
+
's0.stale': 'The previous vault is no longer usable: {path}. Choose it again, or move it back.',
|
|
119
|
+
's0.noVault': 'This folder has no .obsidian. You can still connect if it is a vault root.',
|
|
120
|
+
's1.connectedTo': 'Connected',
|
|
121
|
+
's1.change': 'Change vault',
|
|
122
|
+
's1.title': 'Capture a first idea',
|
|
123
|
+
's1.body': 'An observation, a question, or a thought you have not worked out yet. Saving puts it in the vault’s Grove/ folder.',
|
|
124
|
+
's1.placeholder': 'For example: why do coordination costs rise as an organization grows?',
|
|
125
|
+
's1.save': 'Save to vault',
|
|
126
|
+
's1.saving': 'Saving…',
|
|
127
|
+
's1.saved': 'Saved',
|
|
128
|
+
's1.savedBody': 'Written to the vault. Open the Grove/ folder in Obsidian to see it. Back in the conversation, ask AI to use grove_role and keep thinking.',
|
|
129
|
+
's1.view': 'View overview',
|
|
130
|
+
's1.another': 'Another way to start',
|
|
131
|
+
's1.prompt': 'Use grove_role as my thinking partner and help me unpack this question: <your question>',
|
|
132
|
+
's1.copy': 'Copy prompt',
|
|
133
|
+
's1.copied': 'Copied',
|
|
134
|
+
's2.title': 'Your knowledge base',
|
|
135
|
+
's2.total': 'assets',
|
|
136
|
+
's2.recent': 'Recently updated',
|
|
137
|
+
's2.empty': 'No notes yet.',
|
|
138
|
+
's2.continue': 'Continue in the conversation',
|
|
139
|
+
's2.prompt': 'Use grove_role as my thinking partner and continue from the last question.',
|
|
140
|
+
's2.count.inspiration': 'Inspirations',
|
|
141
|
+
's2.count.question': 'Questions',
|
|
142
|
+
's2.count.research': 'Research',
|
|
143
|
+
's2.count.claim': 'Claims',
|
|
144
|
+
's2.count.evidence': 'Evidence',
|
|
145
|
+
'type.inspiration': 'Inspiration',
|
|
146
|
+
'type.question': 'Question',
|
|
147
|
+
'type.concept': 'Concept',
|
|
148
|
+
'type.theory': 'Theory',
|
|
149
|
+
'type.claim': 'Claim',
|
|
150
|
+
'type.evidence': 'Evidence',
|
|
151
|
+
'type.method': 'Method',
|
|
152
|
+
'type.output': 'Output',
|
|
153
|
+
'type.exploration': 'Exploration',
|
|
154
|
+
'type.research': 'Research',
|
|
155
|
+
'foot.privacy': 'Grove reads and writes only the vault’s Grove/ folder, never other notes.',
|
|
156
|
+
'foot.confirm': 'Content drafted by AI is saved only after you confirm it in the terminal.',
|
|
157
|
+
'foot.data': 'Your data is Markdown you can back up or move at any time.',
|
|
158
|
+
'err.invalid-path': 'That path is not valid. Use an absolute path.',
|
|
159
|
+
'err.not-directory': 'That path is not a folder.',
|
|
160
|
+
'err.symlink': 'That path is a symlink. Choose a real directory.',
|
|
161
|
+
'err.save-unavailable': 'This profile cannot save plugin configuration. Set GROVE_VAULT manually.',
|
|
162
|
+
'err.not-configured': 'No vault is connected yet.',
|
|
163
|
+
'err.invalid-body': 'Write something first.',
|
|
164
|
+
'err.too-large': 'That is too long. Keep it under 128 KiB.',
|
|
165
|
+
'err.network': 'The panel service is unreachable. Restart DSH and try again.',
|
|
166
|
+
'err.timeout': 'The configuration was submitted but has not taken effect. Restart DSH and check again.',
|
|
167
|
+
'err.internal': 'That did not work. Try again.',
|
|
168
|
+
'err.retry': 'Retry',
|
|
169
|
+
'err.clipboard': 'Copy was blocked. Select the text manually.',
|
|
170
|
+
'loading': 'Reading the vault…',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
var boundT = null
|
|
174
|
+
var services = { workspace: null }
|
|
175
|
+
|
|
176
|
+
function T(key, vars) {
|
|
177
|
+
if (boundT) {
|
|
178
|
+
try {
|
|
179
|
+
var resolved = boundT(key, vars)
|
|
180
|
+
if (resolved !== undefined && resolved !== null && resolved !== key) return resolved
|
|
181
|
+
} catch (error) { /* fall through to the bundled dictionary */ }
|
|
182
|
+
}
|
|
183
|
+
var value = ZH[key] !== undefined ? ZH[key] : EN[key]
|
|
184
|
+
if (value === undefined) return key
|
|
185
|
+
if (vars) {
|
|
186
|
+
value = value.replace(/\{(\w+)\}/g, function (match, name) {
|
|
187
|
+
return vars[name] === undefined ? match : String(vars[name])
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return value
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function api(path, body) {
|
|
194
|
+
var request = body === undefined
|
|
195
|
+
? { headers: { accept: 'application/json' } }
|
|
196
|
+
: { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body) }
|
|
197
|
+
return fetch(path, request).then(function (response) {
|
|
198
|
+
return response.json().catch(function () { return {} }).then(function (data) {
|
|
199
|
+
if (response.ok) return data
|
|
200
|
+
var error = new Error((data.error && data.error.message) || ('HTTP ' + response.status))
|
|
201
|
+
error.code = data.error && data.error.code
|
|
202
|
+
throw error
|
|
203
|
+
})
|
|
204
|
+
}, function () {
|
|
205
|
+
var error = new Error('network')
|
|
206
|
+
error.code = 'network'
|
|
207
|
+
throw error
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function messageOf(error) {
|
|
212
|
+
if (error && error.code && ZH['err.' + error.code] !== undefined) return T('err.' + error.code)
|
|
213
|
+
if (error && error.code === 'network') return T('err.network')
|
|
214
|
+
return (error && error.message) || T('err.internal')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function when(iso) {
|
|
218
|
+
if (!iso) return ''
|
|
219
|
+
var date = new Date(iso)
|
|
220
|
+
if (isNaN(date.getTime())) return ''
|
|
221
|
+
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function copyText(text) {
|
|
225
|
+
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text)
|
|
226
|
+
return Promise.reject(new Error('clipboard'))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function waitForVault(expected, tries) {
|
|
230
|
+
return api(STATUS).catch(function () { return null }).then(function (status) {
|
|
231
|
+
if (status && status.configured && status.usable && (expected === undefined || status.vault === expected)) return status
|
|
232
|
+
if (tries <= 0) {
|
|
233
|
+
var error = new Error('timeout')
|
|
234
|
+
error.code = 'timeout'
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
return new Promise(function (resolve) { setTimeout(resolve, 700) }).then(function () {
|
|
238
|
+
return waitForVault(expected, tries - 1)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ────────────────────────────────────────────────────── the page
|
|
244
|
+
function GrovePage() {
|
|
245
|
+
var [status, setStatus] = useState(null)
|
|
246
|
+
var [loading, setLoading] = useState(true)
|
|
247
|
+
var [error, setError] = useState(null)
|
|
248
|
+
var [busy, setBusy] = useState('')
|
|
249
|
+
var [manual, setManual] = useState(false)
|
|
250
|
+
var [draft, setDraft] = useState('')
|
|
251
|
+
var [pathDraft, setPathDraft] = useState('')
|
|
252
|
+
var [firstNote, setFirstNote] = useState(null)
|
|
253
|
+
var [copied, setCopied] = useState(false)
|
|
254
|
+
var [changing, setChanging] = useState(false)
|
|
255
|
+
var mounted = useRef(true)
|
|
256
|
+
|
|
257
|
+
useEffect(function () {
|
|
258
|
+
mounted.current = true
|
|
259
|
+
load()
|
|
260
|
+
return function () { mounted.current = false }
|
|
261
|
+
}, [])
|
|
262
|
+
|
|
263
|
+
function load() {
|
|
264
|
+
setLoading(true)
|
|
265
|
+
return api(STATUS).then(function (next) {
|
|
266
|
+
if (!mounted.current) return
|
|
267
|
+
setStatus(next)
|
|
268
|
+
setError(null)
|
|
269
|
+
}, function (problem) {
|
|
270
|
+
if (!mounted.current) return
|
|
271
|
+
setError(problem)
|
|
272
|
+
}).then(function () {
|
|
273
|
+
if (mounted.current) setLoading(false)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function connect(path) {
|
|
278
|
+
setBusy('connect')
|
|
279
|
+
setError(null)
|
|
280
|
+
return api(CONFIG, { path: path }).then(function () {
|
|
281
|
+
return waitForVault(path, 12)
|
|
282
|
+
}).then(function (next) {
|
|
283
|
+
if (!mounted.current) return
|
|
284
|
+
setStatus(next)
|
|
285
|
+
setManual(false)
|
|
286
|
+
setChanging(false)
|
|
287
|
+
}, function (problem) {
|
|
288
|
+
if (!mounted.current) return
|
|
289
|
+
setError(problem)
|
|
290
|
+
}).then(function () {
|
|
291
|
+
if (mounted.current) setBusy('')
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function pickFolder() {
|
|
296
|
+
var workspace = services.workspace
|
|
297
|
+
if (!workspace || typeof workspace.pickDirectory !== 'function') {
|
|
298
|
+
setManual(true)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
setBusy('pick')
|
|
302
|
+
Promise.resolve(workspace.pickDirectory()).then(function (path) {
|
|
303
|
+
if (!path) {
|
|
304
|
+
if (mounted.current) setBusy('')
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if (mounted.current) setBusy('')
|
|
308
|
+
connect(path)
|
|
309
|
+
}, function () {
|
|
310
|
+
if (!mounted.current) return
|
|
311
|
+
setBusy('')
|
|
312
|
+
setManual(true)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function submitManual() {
|
|
317
|
+
var path = pathDraft.trim()
|
|
318
|
+
if (path === '') return
|
|
319
|
+
connect(path)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function saveFirst() {
|
|
323
|
+
var body = draft.trim()
|
|
324
|
+
if (body === '') return
|
|
325
|
+
setBusy('capture')
|
|
326
|
+
setError(null)
|
|
327
|
+
api(CAPTURE, { body: body }).then(function (note) {
|
|
328
|
+
if (!mounted.current) return
|
|
329
|
+
setFirstNote(note)
|
|
330
|
+
setDraft('')
|
|
331
|
+
return api(STATUS).then(function (next) {
|
|
332
|
+
if (mounted.current) setStatus(next)
|
|
333
|
+
})
|
|
334
|
+
}, function (problem) {
|
|
335
|
+
if (mounted.current) setError(problem)
|
|
336
|
+
}).then(function () {
|
|
337
|
+
if (mounted.current) setBusy('')
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function copyPrompt(text) {
|
|
342
|
+
copyText(text).then(function () {
|
|
343
|
+
if (!mounted.current) return
|
|
344
|
+
setCopied(true)
|
|
345
|
+
setTimeout(function () { if (mounted.current) setCopied(false) }, 1600)
|
|
346
|
+
}, function (problem) {
|
|
347
|
+
if (mounted.current) setError(problem)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
var connected = !!(status && status.configured && status.usable)
|
|
352
|
+
var total = status && status.counts ? Number(status.counts.total) || 0 : 0
|
|
353
|
+
var connectView = h(Connect, {
|
|
354
|
+
busy: busy, manual: manual, error: error, pathDraft: pathDraft,
|
|
355
|
+
stale: !changing && status && status.configured && !status.usable ? status.vault : null,
|
|
356
|
+
onPath: setPathDraft, onPick: pickFolder, onSubmit: submitManual,
|
|
357
|
+
onManual: function () { setManual(true) },
|
|
358
|
+
onManualCancel: function () { setManual(false) },
|
|
359
|
+
onCancel: changing || connected ? function () { setChanging(false); setManual(false) } : null,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
return h('div', { className: 'gr-root' },
|
|
363
|
+
h('style', null, CSS),
|
|
364
|
+
h('div', { className: 'gr-scroll' },
|
|
365
|
+
h('div', { className: 'gr-inner' },
|
|
366
|
+
h(Header, {
|
|
367
|
+
status: status, loading: loading, error: error,
|
|
368
|
+
onChange: connected ? function () {
|
|
369
|
+
setPathDraft(status.vault || '')
|
|
370
|
+
setManual(false)
|
|
371
|
+
setChanging(true)
|
|
372
|
+
} : null,
|
|
373
|
+
}),
|
|
374
|
+
error ? h(ErrorNote, { error: error, onRetry: load }) : null,
|
|
375
|
+
loading && status === null
|
|
376
|
+
? h('div', { className: 'gr-card' }, h('div', { className: 'gr-body' }, T('loading')))
|
|
377
|
+
: changing || !connected
|
|
378
|
+
? connectView
|
|
379
|
+
: total > 0 && firstNote === null
|
|
380
|
+
? h(Overview, { status: status, onCopy: copyPrompt })
|
|
381
|
+
: h(FirstNote, {
|
|
382
|
+
status: status, draft: draft, busy: busy, note: firstNote,
|
|
383
|
+
onDraft: setDraft, onSave: saveFirst, onCopy: copyPrompt,
|
|
384
|
+
onOverview: function () { setFirstNote(null) },
|
|
385
|
+
}),
|
|
386
|
+
h(Footer, null),
|
|
387
|
+
),
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function Header(props) {
|
|
393
|
+
var status = props.status
|
|
394
|
+
var connected = !!(status && status.configured && status.usable)
|
|
395
|
+
var label = props.loading && !status ? T('status.checking') : connected ? T('status.connected') : T('status.disconnected')
|
|
396
|
+
return h('div', { className: 'gr-head' },
|
|
397
|
+
h('div', { className: 'gr-mark' }, h(GroveMark, { size: 26 })),
|
|
398
|
+
h('div', { className: 'gr-headtext' },
|
|
399
|
+
h('div', { className: 'gr-title' }, T('panel')),
|
|
400
|
+
h('div', { className: 'gr-sub' }, status && status.vault ? status.vault : T('subtitle')),
|
|
401
|
+
),
|
|
402
|
+
h('div', { className: 'gr-head-actions' },
|
|
403
|
+
props.onChange
|
|
404
|
+
? h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', onClick: props.onChange }, T('s1.change'))
|
|
405
|
+
: null,
|
|
406
|
+
h('span', { className: 'gr-pill', 'aria-live': 'polite' },
|
|
407
|
+
h('span', { className: 'gr-dot ' + (connected ? 'gr-dot-ok' : '') }),
|
|
408
|
+
label,
|
|
409
|
+
connected && status.writable === false ? h('span', null, ' · ' + T('status.readonly')) : null,
|
|
410
|
+
),
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function Connect(props) {
|
|
416
|
+
return h('div', { className: 'gr-card' },
|
|
417
|
+
props.stale ? h('div', { className: 'gr-err' }, T('s0.stale', { path: props.stale })) : null,
|
|
418
|
+
h('div', { className: 'gr-h2' }, T('s0.title')),
|
|
419
|
+
h('div', { className: 'gr-body' }, T('s0.body')),
|
|
420
|
+
h('div', { className: 'gr-steps' },
|
|
421
|
+
h('div', { className: 'gr-step' }, h('b', null, '1'), T('s0.step1')),
|
|
422
|
+
h('div', { className: 'gr-step' }, h('b', null, '2'), T('s0.step2')),
|
|
423
|
+
h('div', { className: 'gr-step' }, h('b', null, '3'), T('s0.step3')),
|
|
424
|
+
),
|
|
425
|
+
props.manual
|
|
426
|
+
? h('div', { className: 'gr-row' },
|
|
427
|
+
h('input', {
|
|
428
|
+
className: 'gr-input', value: props.pathDraft, spellCheck: false,
|
|
429
|
+
placeholder: T('s0.placeholder'), 'aria-label': T('s0.placeholder'),
|
|
430
|
+
onChange: function (event) { props.onPath(event.target.value) },
|
|
431
|
+
onKeyDown: function (event) { if (event.key === 'Enter') props.onSubmit() },
|
|
432
|
+
}),
|
|
433
|
+
h('button', { className: 'gr-btn gr-btn-sm', disabled: props.busy !== '', onClick: props.onSubmit }, T('s0.connect')),
|
|
434
|
+
h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', disabled: props.busy !== '', onClick: props.onManualCancel }, T('s0.cancel')),
|
|
435
|
+
)
|
|
436
|
+
: h('div', { className: 'gr-row' },
|
|
437
|
+
h('button', { className: 'gr-btn', disabled: props.busy !== '', onClick: props.onPick }, props.busy === 'pick' ? T('status.checking') : T('s0.pick')),
|
|
438
|
+
h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', disabled: props.busy !== '', onClick: props.onManual }, T('s0.manual')),
|
|
439
|
+
props.onCancel
|
|
440
|
+
? h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', disabled: props.busy !== '', onClick: props.onCancel }, T('s0.cancel'))
|
|
441
|
+
: null,
|
|
442
|
+
),
|
|
443
|
+
h('div', { className: 'gr-meta' }, T('s0.hint')),
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function FirstNote(props) {
|
|
448
|
+
var status = props.status
|
|
449
|
+
if (props.note) {
|
|
450
|
+
return h('div', { className: 'gr-card' },
|
|
451
|
+
h('div', { className: 'gr-h2 gr-ok' }, '✓ ' + T('s1.saved')),
|
|
452
|
+
h('div', { className: 'gr-body' }, T('s1.savedBody')),
|
|
453
|
+
h('div', { className: 'gr-row' }, h('span', { className: 'gr-path' }, props.note.path)),
|
|
454
|
+
h('div', { className: 'gr-row' }, h('button', { className: 'gr-btn gr-btn-sm', onClick: props.onOverview }, T('s1.view'))),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
return h(React.Fragment, null,
|
|
458
|
+
h('div', { className: 'gr-card' },
|
|
459
|
+
h('div', { className: 'gr-meta' }, T('s1.connectedTo') + ' · ' + (status.vault || '')),
|
|
460
|
+
h('div', { className: 'gr-h2' }, T('s1.title')),
|
|
461
|
+
h('div', { className: 'gr-body' }, T('s1.body')),
|
|
462
|
+
h('textarea', {
|
|
463
|
+
className: 'gr-textarea', value: props.draft, placeholder: T('s1.placeholder'),
|
|
464
|
+
'aria-label': T('s1.title'),
|
|
465
|
+
onChange: function (event) { props.onDraft(event.target.value) },
|
|
466
|
+
onKeyDown: function (event) {
|
|
467
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') props.onSave()
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
470
|
+
h('div', { className: 'gr-row' },
|
|
471
|
+
h('button', { className: 'gr-btn', disabled: props.busy !== '' || props.draft.trim() === '', onClick: props.onSave },
|
|
472
|
+
props.busy === 'capture' ? T('s1.saving') : T('s1.save')),
|
|
473
|
+
h('span', { className: 'gr-meta' }, '⌘↵'),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
h('div', { className: 'gr-card' },
|
|
477
|
+
h('div', { className: 'gr-h2' }, T('s1.another')),
|
|
478
|
+
h('pre', { className: 'gr-code' }, T('s1.prompt')),
|
|
479
|
+
h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', onClick: function () { props.onCopy(T('s1.prompt')) } }, T('s1.copy')),
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function Overview(props) {
|
|
485
|
+
var status = props.status
|
|
486
|
+
var counts = status.counts || {}
|
|
487
|
+
var stats = ['inspiration', 'question', 'research', 'claim', 'evidence'].map(function (type) {
|
|
488
|
+
return h('div', { className: 'gr-stat', key: type },
|
|
489
|
+
h('b', null, String(counts[type] || 0)),
|
|
490
|
+
h('span', null, T('s2.count.' + type)),
|
|
491
|
+
)
|
|
492
|
+
})
|
|
493
|
+
var recent = status.recent || []
|
|
494
|
+
return h(React.Fragment, null,
|
|
495
|
+
h('div', { className: 'gr-card' },
|
|
496
|
+
h('div', { className: 'gr-row', style: { justifyContent: 'space-between' } },
|
|
497
|
+
h('div', { className: 'gr-h2' }, T('s2.title')),
|
|
498
|
+
h('div', { className: 'gr-meta' }, String(counts.total || 0) + ' ' + T('s2.total')),
|
|
499
|
+
),
|
|
500
|
+
h('div', { className: 'gr-stats' }, stats),
|
|
501
|
+
),
|
|
502
|
+
h('div', { className: 'gr-card' },
|
|
503
|
+
h('div', { className: 'gr-h2' }, T('s2.recent')),
|
|
504
|
+
recent.length === 0
|
|
505
|
+
? h('div', { className: 'gr-body' }, T('s2.empty'))
|
|
506
|
+
: h('div', { className: 'gr-recent' }, recent.map(function (item) {
|
|
507
|
+
return h('div', { className: 'gr-item', key: item.id + ':' + item.version },
|
|
508
|
+
h('span', { className: 'gr-type' }, T('type.' + item.type)),
|
|
509
|
+
h('span', { className: 'gr-itemtitle', title: item.title || '' }, item.title || item.id),
|
|
510
|
+
h('span', { className: 'gr-meta' }, 'v' + item.version + (item.at ? ' · ' + when(item.at) : '')),
|
|
511
|
+
)
|
|
512
|
+
})),
|
|
513
|
+
),
|
|
514
|
+
h('div', { className: 'gr-card' },
|
|
515
|
+
h('div', { className: 'gr-h2' }, T('s2.continue')),
|
|
516
|
+
h('pre', { className: 'gr-code' }, T('s2.prompt')),
|
|
517
|
+
h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', onClick: function () { props.onCopy(T('s2.prompt')) } }, T('s1.copy')),
|
|
518
|
+
),
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function ErrorNote(props) {
|
|
523
|
+
return h('div', { className: 'gr-card', role: 'alert' },
|
|
524
|
+
h('div', { className: 'gr-err' }, messageOf(props.error)),
|
|
525
|
+
h('button', { className: 'gr-btn gr-btn-ghost gr-btn-sm', onClick: props.onRetry }, T('err.retry')),
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function Footer() {
|
|
530
|
+
return h('div', { className: 'gr-foot' },
|
|
531
|
+
h('div', null, T('foot.privacy')),
|
|
532
|
+
h('div', null, T('foot.confirm')),
|
|
533
|
+
h('div', null, T('foot.data')),
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function GroveMark(props) {
|
|
538
|
+
var size = Number(props && props.size) || 36
|
|
539
|
+
return h('svg', {
|
|
540
|
+
xmlns: 'http://www.w3.org/2000/svg', width: size, height: size, viewBox: '0 0 36 36',
|
|
541
|
+
'aria-hidden': true, focusable: 'false',
|
|
542
|
+
},
|
|
543
|
+
h('g', { transform: 'translate(18 18) scale(1.12) translate(-18.6 -20.9)' },
|
|
544
|
+
h('path', { d: 'M16.9 30.4h2.2l-.65-12.3h-.9z', fill: '#1B3A2E' }),
|
|
545
|
+
h('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' }),
|
|
546
|
+
h('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' }),
|
|
547
|
+
h('path', { d: 'M15.4 31.5h5.2', stroke: '#C98A3B', 'stroke-width': 2.2, 'stroke-linecap': 'round', fill: 'none' }),
|
|
548
|
+
))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function GroveIcon(props) {
|
|
552
|
+
var size = Number(props && props.size) || 16
|
|
553
|
+
return h(GroveMark, { size: size })
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
var CSS = [
|
|
557
|
+
'.gr-root{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:1.6;box-sizing:border-box;padding-top:min(var(--dsh-frame-top-clearance,0px),28px)}',
|
|
558
|
+
'.gr-scroll{flex:1 1 auto;min-height:0;overflow:auto}',
|
|
559
|
+
'.gr-inner{max-width:760px;margin:0 auto;padding:28px 24px 40px;display:flex;flex-direction:column;gap:18px;box-sizing:border-box}',
|
|
560
|
+
'.gr-head{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap}',
|
|
561
|
+
'.gr-mark{flex:none;width:44px;height:44px;border-radius:12px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);display:grid;place-items:center}',
|
|
562
|
+
'.gr-headtext{min-width:0;flex:1 1 220px}',
|
|
563
|
+
'.gr-title{font-size:20px;font-weight:600;line-height:1.3}',
|
|
564
|
+
'.gr-sub{color:var(--dsw-alias-label-secondary);font-size:13px;margin-top:2px;word-break:break-all}',
|
|
565
|
+
'.gr-head-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex:none}',
|
|
566
|
+
'.gr-pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 10px;white-space:nowrap;background:var(--dsw-alias-bg-layer-1)}',
|
|
567
|
+
'.gr-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
|
|
568
|
+
'.gr-dot-ok{background:var(--dsw-alias-state-success-primary)}',
|
|
569
|
+
'.gr-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:20px;display:flex;flex-direction:column;gap:12px;box-sizing:border-box}',
|
|
570
|
+
'.gr-h2{font-size:15px;font-weight:600;line-height:1.4}',
|
|
571
|
+
'.gr-body{color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
572
|
+
'.gr-meta{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
573
|
+
'.gr-btn{appearance:none;border:0;border-radius:8px;background:var(--dsw-alias-brand-primary);color:#fff;font:inherit;font-size:13px;font-weight:600;padding:8px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:8px;align-self:flex-start}',
|
|
574
|
+
'.gr-btn:hover{opacity:.92}',
|
|
575
|
+
'.gr-btn:disabled{opacity:.5;cursor:default}',
|
|
576
|
+
'.gr-btn-ghost{background:transparent;color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2)}',
|
|
577
|
+
'.gr-btn-sm{padding:5px 10px;font-size:12px;font-weight:500}',
|
|
578
|
+
'.gr-input,.gr-textarea{width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:9px 11px;outline:none}',
|
|
579
|
+
'.gr-textarea{min-height:96px;resize:vertical;line-height:1.6}',
|
|
580
|
+
'.gr-input:focus-visible,.gr-textarea:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
581
|
+
'.gr-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;min-width:0}',
|
|
582
|
+
'.gr-err{color:var(--dsw-alias-state-error-primary);font-size:12px;white-space:pre-wrap}',
|
|
583
|
+
'.gr-ok{color:var(--dsw-alias-state-success-primary)}',
|
|
584
|
+
'.gr-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:2px 6px;word-break:break-all}',
|
|
585
|
+
'.gr-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}',
|
|
586
|
+
'.gr-step{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px;font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base)}',
|
|
587
|
+
'.gr-step b{display:block;color:var(--dsw-alias-label-primary);font-size:12px;margin-bottom:2px}',
|
|
588
|
+
'.gr-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:10px}',
|
|
589
|
+
'.gr-stat{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px;background:var(--dsw-alias-bg-base);min-width:0}',
|
|
590
|
+
'.gr-stat b{display:block;font-size:20px;font-weight:600;line-height:1.2}',
|
|
591
|
+
'.gr-stat span{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
592
|
+
'.gr-recent{display:flex;flex-direction:column;min-width:0}',
|
|
593
|
+
'.gr-item{display:flex;align-items:baseline;gap:10px;padding:9px 0;border-bottom:1px solid var(--dsw-alias-border-l1);min-width:0}',
|
|
594
|
+
'.gr-item:last-child{border-bottom:0}',
|
|
595
|
+
'.gr-type{flex:none;font-size:11px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:1px 8px}',
|
|
596
|
+
'.gr-itemtitle{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
597
|
+
'.gr-code{white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6;margin:0}',
|
|
598
|
+
'.gr-foot{border-top:1px solid var(--dsw-alias-border-l1);padding-top:12px;color:var(--dsw-alias-label-secondary);font-size:12px;display:flex;flex-direction:column;gap:4px}',
|
|
599
|
+
'@media (max-width:560px){.gr-inner{padding:18px 14px 32px}.gr-head{flex-wrap:wrap}.gr-steps{grid-template-columns:1fr}.gr-stats{grid-template-columns:repeat(2,1fr)}.gr-stat:last-child:nth-child(odd){grid-column:1/-1}.gr-btn{align-self:stretch;justify-content:center}}',
|
|
600
|
+
'@media (prefers-reduced-motion:reduce){.gr-btn{transition:none}}',
|
|
601
|
+
].join('\n')
|
|
602
|
+
|
|
603
|
+
// ────────────────────────────────────────────────────── registration
|
|
604
|
+
function apply(ctx) {
|
|
605
|
+
var locale = ctx.locale
|
|
606
|
+
if (locale) {
|
|
607
|
+
try {
|
|
608
|
+
if (typeof ctx.effect === 'function') {
|
|
609
|
+
ctx.effect(function () { return locale.register(NS, { zh: ZH, en: EN }) }, 'grove: dictionaries')
|
|
610
|
+
}
|
|
611
|
+
if (typeof locale.bind === 'function') boundT = locale.bind(NS)
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.warn('[grove] locale registration failed', error)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
services.workspace = ctx.get('uiWorkspace')
|
|
618
|
+
} catch (error) {
|
|
619
|
+
services.workspace = null
|
|
620
|
+
}
|
|
621
|
+
var slots = ctx.slots
|
|
622
|
+
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
623
|
+
console.warn('[grove] slots service unavailable; the panel is not mounted')
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
slots.inject('main', function () {
|
|
627
|
+
return slots.register({ name: 'main', key: 'grove', locale: NS }, GrovePage)
|
|
628
|
+
})
|
|
629
|
+
slots.inject('sidebar.panellist', function () {
|
|
630
|
+
return slots.register({
|
|
631
|
+
name: 'sidebar.panellist', id: 'grove', order: 40,
|
|
632
|
+
label: function () { return T('menu') }, locale: NS,
|
|
633
|
+
}, GroveIcon)
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { inject: ['slots', 'locale'], apply: apply }
|
|
638
|
+
},
|
|
639
|
+
})
|
package/docs/DETAIL.en.md
CHANGED
|
@@ -1,37 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
Grove
|
|
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`.
|
|
3
|
+
Open Grove from the left sidebar, connect your own Obsidian vault, and keep what you explore as Markdown notes you can revisit and revise.
|
package/docs/DETAIL.zh.md
CHANGED
|
@@ -1,37 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
Grove
|
|
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`。
|
|
3
|
+
安装后从左侧菜单的 Grove 打开面板,连接自己的 Obsidian vault,把探索留下的笔记保存在可回看、可修订的 Markdown 里。
|
package/docs/preview.svg
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="300" viewBox="0 0 640 300" role="img" aria-label="Grove panel between DeepSeek Harness and an Obsidian vault">
|
|
2
|
+
<rect width="640" height="300" rx="20" fill="#F7F4EC"/>
|
|
3
|
+
<rect x="20" y="20" width="600" height="260" rx="16" fill="#FBF9F3" stroke="#E4DCCC" stroke-width="1.5"/>
|
|
4
|
+
|
|
5
|
+
<!-- DeepSeek Harness window -->
|
|
6
|
+
<g>
|
|
7
|
+
<rect x="44" y="70" width="164" height="160" rx="12" fill="#FFFFFF" stroke="#C3D3C9" stroke-width="2"/>
|
|
8
|
+
<path d="M44 82a12 12 0 0 1 12-12h140a12 12 0 0 1 12 12v8H44z" fill="#E8EFEA"/>
|
|
9
|
+
<circle cx="60" cy="80" r="3" fill="#5FA37E"/>
|
|
10
|
+
<circle cx="72" cy="80" r="3" fill="#C98A3B"/>
|
|
11
|
+
<circle cx="84" cy="80" r="3" fill="#C3D3C9"/>
|
|
12
|
+
<rect x="60" y="112" width="98" height="16" rx="8" fill="#E8EFEA"/>
|
|
13
|
+
<rect x="60" y="144" width="126" height="16" rx="8" fill="#DCEAE1"/>
|
|
14
|
+
<rect x="60" y="180" width="72" height="14" rx="7" fill="#2F7D5B" opacity="0.85"/>
|
|
15
|
+
</g>
|
|
16
|
+
|
|
17
|
+
<!-- arrows -->
|
|
18
|
+
<g stroke="#C98A3B" stroke-width="3" stroke-linecap="round" stroke-dasharray="7 7" fill="none">
|
|
19
|
+
<path d="M218 150h54"/>
|
|
20
|
+
<path d="M368 150h54"/>
|
|
21
|
+
</g>
|
|
22
|
+
<path d="M276 143l10 7-10 7z" fill="#C98A3B"/>
|
|
23
|
+
<path d="M426 143l10 7-10 7z" fill="#C98A3B"/>
|
|
24
|
+
|
|
25
|
+
<!-- Grove mark -->
|
|
26
|
+
<g transform="translate(282 100) scale(1.9)">
|
|
27
|
+
<path d="M16.9 30.4h2.2l-.65-12.3h-.9z" fill="#1B3A2E"/>
|
|
28
|
+
<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"/>
|
|
29
|
+
<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"/>
|
|
30
|
+
<path d="M15.4 31.5h5.2" stroke="#C98A3B" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
|
31
|
+
</g>
|
|
32
|
+
|
|
33
|
+
<!-- Obsidian vault window -->
|
|
34
|
+
<g>
|
|
35
|
+
<rect x="432" y="70" width="164" height="160" rx="12" fill="#FFFFFF" stroke="#C3D3C9" stroke-width="2"/>
|
|
36
|
+
<path d="M432 82a12 12 0 0 1 12-12h60a12 12 0 0 1 12 12v6h-84z" fill="#E8EFEA"/>
|
|
37
|
+
<rect x="448" y="104" width="52" height="34" rx="5" fill="#E8EFEA"/>
|
|
38
|
+
<rect x="508" y="104" width="52" height="34" rx="5" fill="#DCEAE1"/>
|
|
39
|
+
<rect x="448" y="146" width="52" height="34" rx="5" fill="#DCEAE1"/>
|
|
40
|
+
<rect x="508" y="146" width="52" height="34" rx="5" fill="#E8EFEA"/>
|
|
41
|
+
<rect x="448" y="188" width="112" height="24" rx="5" fill="#F1EEE4" stroke="#E4DCCC"/>
|
|
42
|
+
</g>
|
|
43
|
+
|
|
44
|
+
<!-- amber baseline -->
|
|
45
|
+
<path d="M300 214h40" stroke="#C98A3B" stroke-width="4" stroke-linecap="round"/>
|
|
46
|
+
</svg>
|
package/locale/en.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Grove",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Open Grove from the left sidebar after installing: connect your Obsidian vault, capture a first idea, then return to the conversation and let AI question it, compare explanations, and look for counterexamples. Notes stay as Markdown in the vault's Grove/ folder, ready to revisit, revise, and share; content drafted by AI is saved only after you confirm it in the terminal. Grove reads and writes only that subfolder.",
|
|
5
5
|
"detail": "docs/DETAIL.en.md"
|
|
6
6
|
}
|
|
7
7
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Grove(知林)",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "安装后从左侧菜单的 Grove 打开面板:连接你的 Obsidian vault,写下第一条灵感,再回到会话里让 AI 陪你追问、比较解释、查找反例。笔记以 Markdown 留在 vault 的 Grove/ 文件夹,可回看、修订和分享;AI 起草的内容需要你在终端确认后才落盘。Grove 只读写这个子文件夹。",
|
|
5
5
|
"detail": "docs/DETAIL.zh.md"
|
|
6
6
|
}
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-grove",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Explore with AI in DeepSeek Harness and grow knowledge you can revisit and revise in your own Obsidian vault",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/plugin.js",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/plugin.js",
|
|
10
|
+
"./client": "./client.js",
|
|
10
11
|
"./package.json": "./package.json",
|
|
11
12
|
"./locale/*.json": "./locale/*.json",
|
|
12
13
|
"./icon": "./icon.svg"
|
|
13
14
|
},
|
|
14
15
|
"icon": "icon.svg",
|
|
15
16
|
"bin": {
|
|
16
|
-
"grove": "
|
|
17
|
+
"grove": "src/cli.js"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src",
|
|
21
|
+
"client.js",
|
|
20
22
|
"cordis.patch.yml",
|
|
21
23
|
"locale/*.json",
|
|
22
24
|
"icon.svg",
|
|
23
25
|
"docs/DETAIL.en.md",
|
|
24
|
-
"docs/DETAIL.zh.md"
|
|
26
|
+
"docs/DETAIL.zh.md",
|
|
27
|
+
"docs/preview.svg"
|
|
25
28
|
],
|
|
26
29
|
"engines": {
|
|
27
30
|
"node": ">=22",
|
|
@@ -40,6 +43,14 @@
|
|
|
40
43
|
"dsh": {
|
|
41
44
|
"bundle": {
|
|
42
45
|
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web",
|
|
49
|
+
"immediately": true,
|
|
50
|
+
"inject": [
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
53
|
+
]
|
|
43
54
|
}
|
|
44
55
|
},
|
|
45
56
|
"peerDependencies": {
|
package/src/plugin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { Vault, types } from './vault.js'
|
|
3
3
|
import { propose, roles } from './proposals.js'
|
|
4
|
+
import { registerRoutes } from './server.js'
|
|
4
5
|
|
|
5
6
|
export const name = 'grove'
|
|
6
7
|
export const inject = ['tools']
|
|
@@ -10,10 +11,30 @@ const output = {
|
|
|
10
11
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** The vault a row configured, else GROVE_VAULT. The panel reports which one is in use. */
|
|
15
|
+
function configuredVault(config) {
|
|
16
|
+
const fromConfig = typeof config.vault === 'string' ? config.vault.trim() : ''
|
|
17
|
+
const fromEnv = (process.env.GROVE_VAULT ?? '').trim()
|
|
18
|
+
if (fromConfig !== '') return { path: fromConfig, source: 'config' }
|
|
19
|
+
if (fromEnv !== '') return { path: fromEnv, source: 'env' }
|
|
20
|
+
return { path: undefined, source: null }
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export function apply(ctx, config = {}) {
|
|
14
|
-
const path = config
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
const { path, source } = configuredVault(config)
|
|
25
|
+
|
|
26
|
+
// The panel routes stay registered without a vault so the cold start can pick one.
|
|
27
|
+
if (typeof ctx.inject === 'function') registerRoutes(ctx, { current: () => configuredVault(config) })
|
|
28
|
+
|
|
29
|
+
if (path === undefined) return
|
|
30
|
+
let vault
|
|
31
|
+
try {
|
|
32
|
+
vault = new Vault(path)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// An unusable path must not take the whole plugin down: the panel shows the problem instead.
|
|
35
|
+
console.warn(`[grove] ${error.message}`)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
17
38
|
const allowed = new Set(config.readableIds ?? (process.env.GROVE_READABLE_IDS ?? '').split(',').map(id => id.trim()).filter(Boolean))
|
|
18
39
|
|
|
19
40
|
ctx.tools.register(defineTool({
|
package/src/server.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Same-origin routes and vault operations for the Grove panel. */
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { access, realpath, stat } from 'node:fs/promises'
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
|
+
import { Vault } from './vault.js'
|
|
6
|
+
|
|
7
|
+
export const routes = {
|
|
8
|
+
status: '/api/grove/status',
|
|
9
|
+
config: '/api/grove/config',
|
|
10
|
+
capture: '/api/grove/capture',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Largest body the panel may post, matching the proposal limit. */
|
|
14
|
+
const maxBody = 128 * 1024
|
|
15
|
+
|
|
16
|
+
function coded(code, message) {
|
|
17
|
+
return Object.assign(new Error(message), { code })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function json(body, status = 200) {
|
|
21
|
+
return new Response(JSON.stringify(body), {
|
|
22
|
+
status,
|
|
23
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusOf(error) {
|
|
28
|
+
switch (error.code) {
|
|
29
|
+
case 'invalid-path':
|
|
30
|
+
case 'not-directory':
|
|
31
|
+
case 'symlink':
|
|
32
|
+
case 'invalid-body':
|
|
33
|
+
return 400
|
|
34
|
+
case 'not-configured':
|
|
35
|
+
return 409
|
|
36
|
+
case 'too-large':
|
|
37
|
+
return 413
|
|
38
|
+
default:
|
|
39
|
+
return 500
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function failure(error) {
|
|
44
|
+
const code = typeof error?.code === 'string' ? error.code : 'internal'
|
|
45
|
+
return json({ error: { code, message: error instanceof Error ? error.message : String(error) } }, statusOf({ code }))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Validate a folder a person chose for the vault. Rejects relative paths and symlinked directories. */
|
|
49
|
+
export async function inspectVault(value) {
|
|
50
|
+
if (typeof value !== 'string' || value.trim() === '') throw coded('invalid-path', 'Choose a folder')
|
|
51
|
+
if (!isAbsolute(value.trim())) throw coded('invalid-path', 'The vault path must be absolute')
|
|
52
|
+
const path = resolve(value.trim())
|
|
53
|
+
const info = await stat(path).catch(() => null)
|
|
54
|
+
if (info === null || !info.isDirectory()) throw coded('not-directory', 'The vault path is not a directory')
|
|
55
|
+
if (await realpath(path) !== path) throw coded('symlink', 'The vault path must be a real directory, not a symlink')
|
|
56
|
+
const looksLikeVault = await stat(join(path, '.obsidian')).then(entry => entry.isDirectory(), () => false)
|
|
57
|
+
return { path, looksLikeVault }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Metadata the panel needs to choose between the cold-start and overview states. Never returns note bodies. */
|
|
61
|
+
export async function vaultStatus(path, source) {
|
|
62
|
+
const empty = {
|
|
63
|
+
configured: false, source: null, vault: null, usable: false, writable: false,
|
|
64
|
+
looksLikeVault: false, counts: { total: 0 }, recent: [],
|
|
65
|
+
}
|
|
66
|
+
if (!path) return empty
|
|
67
|
+
let usable = false
|
|
68
|
+
try {
|
|
69
|
+
usable = (await stat(path)).isDirectory() && await realpath(path) === path
|
|
70
|
+
} catch {
|
|
71
|
+
usable = false
|
|
72
|
+
}
|
|
73
|
+
if (!usable) return { ...empty, configured: true, source, vault: path }
|
|
74
|
+
const writable = await access(path, constants.W_OK).then(() => true, () => false)
|
|
75
|
+
const looksLikeVault = await stat(join(path, '.obsidian')).then(entry => entry.isDirectory(), () => false)
|
|
76
|
+
const vault = new Vault(path)
|
|
77
|
+
const inventory = await vault.inventory().catch(() => new Map())
|
|
78
|
+
const latest = [...inventory].map(([id, versions]) => [id, Math.max(...versions)])
|
|
79
|
+
const notes = []
|
|
80
|
+
for (const [id, version] of latest.slice(0, 300)) {
|
|
81
|
+
try {
|
|
82
|
+
notes.push((await vault.get(id, version)).meta)
|
|
83
|
+
} catch {
|
|
84
|
+
// A note that no longer parses is skipped; the overview still opens.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const counts = { total: latest.length }
|
|
88
|
+
for (const meta of notes) counts[meta.type] = (counts[meta.type] ?? 0) + 1
|
|
89
|
+
const recent = notes
|
|
90
|
+
.sort((a, b) => String(b.modified ?? b.created ?? '').localeCompare(String(a.modified ?? a.created ?? '')))
|
|
91
|
+
.slice(0, 6)
|
|
92
|
+
.map(({ id, title, type, version, status, modified, created }) => ({
|
|
93
|
+
id, title, type, version, ...(status === undefined ? {} : { status }), at: modified ?? created ?? null,
|
|
94
|
+
}))
|
|
95
|
+
return {
|
|
96
|
+
configured: true, source, vault: path, usable, writable, looksLikeVault,
|
|
97
|
+
counts, recent, sampled: latest.length > notes.length,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create the first note from the panel. Only inspirations: research and evidence stay in the terminal. */
|
|
102
|
+
export async function capture(vaultPath, input) {
|
|
103
|
+
if (!vaultPath) throw coded('not-configured', 'Grove has no vault yet')
|
|
104
|
+
const body = input?.body
|
|
105
|
+
if (typeof body !== 'string' || body.trim() === '') throw coded('invalid-body', 'Write something first')
|
|
106
|
+
if (Buffer.byteLength(body) > maxBody) throw coded('too-large', 'The note is too large')
|
|
107
|
+
const vault = new Vault(vaultPath)
|
|
108
|
+
const note = await vault.create({ type: 'inspiration', body, title: input?.title })
|
|
109
|
+
const folder = await vault.folder()
|
|
110
|
+
return {
|
|
111
|
+
id: note.meta.id, type: note.meta.type, title: note.meta.title, version: 1,
|
|
112
|
+
path: join(folder, `${note.meta.id}.v1.md`),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Persist the chosen vault into this plugin row's configuration. */
|
|
117
|
+
export function saveVault(ctx, path) {
|
|
118
|
+
const entry = ctx.fiber?.entry
|
|
119
|
+
const editor = ctx.get?.('configEditor')
|
|
120
|
+
if (!entry || !editor || typeof editor.edit !== 'function') {
|
|
121
|
+
return Promise.reject(coded('save-unavailable', 'This profile cannot save plugin configuration'))
|
|
122
|
+
}
|
|
123
|
+
return editor.edit(entry, current => ({ ...current, vault: path }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readJson(request) {
|
|
127
|
+
try {
|
|
128
|
+
return await request.json()
|
|
129
|
+
} catch {
|
|
130
|
+
throw coded('invalid-body', 'Expected a JSON body')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Register the panel's same-origin routes. They stay available before a vault is configured,
|
|
136
|
+
* so the page can run its cold start.
|
|
137
|
+
* @param ctx - the plugin context.
|
|
138
|
+
* @param options - current vault path/source getter.
|
|
139
|
+
*/
|
|
140
|
+
export function registerRoutes(ctx, options) {
|
|
141
|
+
const delay = Number.isFinite(options.saveDelay) ? options.saveDelay : 100
|
|
142
|
+
ctx.inject(['connection'], (scoped) => {
|
|
143
|
+
const connection = scoped.get('connection')
|
|
144
|
+
if (!connection?.fetch || typeof connection.fetch.register !== 'function') return
|
|
145
|
+
const route = (path, methods, fetch) => {
|
|
146
|
+
scoped.effect(
|
|
147
|
+
() => connection.fetch.register({ path, methods, requestBody: 'buffered', fetch }),
|
|
148
|
+
`grove: ${path}`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
route(routes.status, ['GET'], async () => {
|
|
152
|
+
const { path, source } = options.current()
|
|
153
|
+
return json(await vaultStatus(path, source))
|
|
154
|
+
})
|
|
155
|
+
route(routes.capture, ['POST'], async request => {
|
|
156
|
+
try {
|
|
157
|
+
const body = await readJson(request)
|
|
158
|
+
return json(await capture(options.current().path, body))
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return failure(error)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
route(routes.config, ['POST'], async request => {
|
|
164
|
+
let path
|
|
165
|
+
try {
|
|
166
|
+
path = (await inspectVault((await readJson(request))?.path)).path
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return failure(error)
|
|
169
|
+
}
|
|
170
|
+
// Answer before the configuration reload replaces this plugin instance, otherwise the
|
|
171
|
+
// response can be cut halfway. The page polls status until the new vault is live.
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
saveVault(ctx, path).catch(error => {
|
|
174
|
+
console.warn(`[grove] could not save the vault path: ${error.message}`)
|
|
175
|
+
})
|
|
176
|
+
}, delay)
|
|
177
|
+
return json({ ok: true, pending: true, vault: path })
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
}
|