dsh-grove 0.1.2 → 0.5.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 +34 -73
- package/README.md +35 -76
- package/client.js +1256 -0
- package/docs/DETAIL.en.md +2 -40
- package/docs/DETAIL.zh.md +2 -40
- package/docs/preview.svg +46 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +15 -3
- package/src/adapters/models/http.js +24 -0
- package/src/adapters/parsers/documents.js +31 -0
- package/src/adapters/storage/obsidian/backup.js +104 -0
- package/src/cli.js +11 -3
- package/src/modules/evidence.js +38 -0
- package/src/modules/inspirations.js +18 -0
- package/src/modules/knowledge.js +29 -0
- package/src/modules/model-research.js +27 -0
- package/src/modules/reports.js +57 -0
- package/src/modules/research.js +17 -0
- package/src/modules/version-reference.js +4 -0
- package/src/modules/works.js +27 -0
- package/src/plugin.js +33 -4
- package/src/proposals.js +1 -1
- package/src/server.js +412 -0
- package/src/vault.js +16 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { exactVersion } from './version-reference.js'
|
|
2
|
+
|
|
3
|
+
const basisTypes = new Set(['concept', 'theory', 'claim', 'method', 'report'])
|
|
4
|
+
|
|
5
|
+
export async function saveWork(store, input) {
|
|
6
|
+
if (input.id) {
|
|
7
|
+
const entry = await readWork(store, input.id, input.version)
|
|
8
|
+
return store.update(entry.meta.id, entry.meta.version, { body: input.body })
|
|
9
|
+
}
|
|
10
|
+
const links = []
|
|
11
|
+
if (input.basis) {
|
|
12
|
+
const basis = await store.get(input.basis.id, exactVersion(input.basis.version))
|
|
13
|
+
if (!basisTypes.has(basis.meta.type)) throw new Error('Choose a report or knowledge entry')
|
|
14
|
+
links.push({ kind: 'derived', id: basis.meta.id, version: basis.meta.version })
|
|
15
|
+
}
|
|
16
|
+
return store.create({ type: 'output', title: input.title, body: input.body, links })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readWork(store, id, version) {
|
|
20
|
+
const note = await store.get(id, exactVersion(version))
|
|
21
|
+
if (note.meta.type !== 'output') throw new Error('Choose a work')
|
|
22
|
+
return { ...note, versions: await store.versions(id) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listWorks(store, query) {
|
|
26
|
+
return (await store.list(query)).filter(({ meta }) => meta.type === 'output' && meta.status !== 'archived')
|
|
27
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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 { documentParsers } from './adapters/parsers/documents.js'
|
|
5
|
+
import { httpModel } from './adapters/models/http.js'
|
|
6
|
+
import { registerRoutes } from './server.js'
|
|
4
7
|
|
|
5
8
|
export const name = 'grove'
|
|
6
9
|
export const inject = ['tools']
|
|
@@ -10,10 +13,36 @@ const output = {
|
|
|
10
13
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
/** The vault a row configured, else GROVE_VAULT. The panel reports which one is in use. */
|
|
17
|
+
function configuredVault(config) {
|
|
18
|
+
const fromConfig = typeof config.vault === 'string' ? config.vault.trim() : ''
|
|
19
|
+
const fromEnv = (process.env.GROVE_VAULT ?? '').trim()
|
|
20
|
+
if (fromConfig !== '') return { path: fromConfig, source: 'config' }
|
|
21
|
+
if (fromEnv !== '') return { path: fromEnv, source: 'env' }
|
|
22
|
+
return { path: undefined, source: null }
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
export function apply(ctx, config = {}) {
|
|
14
|
-
const path = config
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
const { path, source } = configuredVault(config)
|
|
27
|
+
|
|
28
|
+
// The panel routes stay registered without a vault so the cold start can pick one.
|
|
29
|
+
if (typeof ctx.inject === 'function') registerRoutes(ctx, {
|
|
30
|
+
current: () => configuredVault(config),
|
|
31
|
+
parsers: documentParsers(config.parsers),
|
|
32
|
+
pdfEnabled: config.parsers?.pdf !== false,
|
|
33
|
+
modelConfig: config.model,
|
|
34
|
+
model: settings => httpModel({ ...settings, credential: process.env.GROVE_MODEL_KEY }),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if (path === undefined) return
|
|
38
|
+
let vault
|
|
39
|
+
try {
|
|
40
|
+
vault = new Vault(path)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// An unusable path must not take the whole plugin down: the panel shows the problem instead.
|
|
43
|
+
console.warn(`[grove] ${error.message}`)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
17
46
|
const allowed = new Set(config.readableIds ?? (process.env.GROVE_READABLE_IDS ?? '').split(',').map(id => id.trim()).filter(Boolean))
|
|
18
47
|
|
|
19
48
|
ctx.tools.register(defineTool({
|
|
@@ -28,7 +57,7 @@ export function apply(ctx, config = {}) {
|
|
|
28
57
|
name: 'grove_propose',
|
|
29
58
|
description: 'When asked, save a draft for local human review. This tool does not create a knowledge asset.',
|
|
30
59
|
parameters: {
|
|
31
|
-
type: { type: 'string', enum: types.filter(type => !['evidence', 'research'].includes(type)), required: true },
|
|
60
|
+
type: { type: 'string', enum: types.filter(type => !['evidence', 'research', 'source', 'report', 'finding'].includes(type)), required: true },
|
|
32
61
|
body: { type: 'string', required: true },
|
|
33
62
|
title: { type: 'string' },
|
|
34
63
|
origin: { type: 'string', description: 'User-approved session reference or source description' },
|
package/src/proposals.js
CHANGED
|
@@ -22,7 +22,7 @@ async function draftFile(vault, id, write = false) {
|
|
|
22
22
|
|
|
23
23
|
export async function propose(vault, input) {
|
|
24
24
|
const { type, title, body, origin } = input
|
|
25
|
-
if (!types.includes(type) || ['research', 'evidence'].includes(type)) throw new Error('Unsupported proposal type')
|
|
25
|
+
if (!types.includes(type) || ['research', 'evidence', 'source', 'report', 'finding'].includes(type)) throw new Error('Unsupported proposal type')
|
|
26
26
|
if (typeof body !== 'string' || !body.trim() || Buffer.byteLength(body) > 128 * 1024) throw new Error('Invalid proposal body')
|
|
27
27
|
if (title && (typeof title !== 'string' || title.length > 300)) throw new Error('Invalid proposal title')
|
|
28
28
|
if (origin && (typeof origin !== 'string' || origin.length > 2000)) throw new Error('Invalid proposal origin')
|
package/src/server.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
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 { captureInspiration, findInspirations, readInspiration } from './modules/inspirations.js'
|
|
6
|
+
import { startResearch, listResearch, saveResearch } from './modules/research.js'
|
|
7
|
+
import { importSource, saveEvidence, openSource } from './modules/evidence.js'
|
|
8
|
+
import { listReports, confirmReport, readReport, draftFromFinding } from './modules/reports.js'
|
|
9
|
+
import { runResearchModel } from './modules/model-research.js'
|
|
10
|
+
import { listKnowledge, readKnowledge, saveKnowledge } from './modules/knowledge.js'
|
|
11
|
+
import { listWorks, readWork, saveWork } from './modules/works.js'
|
|
12
|
+
import { Vault } from './vault.js'
|
|
13
|
+
|
|
14
|
+
export const routes = {
|
|
15
|
+
status: '/api/grove/status',
|
|
16
|
+
config: '/api/grove/config',
|
|
17
|
+
connection: '/api/grove/connection',
|
|
18
|
+
capture: '/api/grove/capture',
|
|
19
|
+
model: '/api/grove/model',
|
|
20
|
+
findings: '/api/grove/findings',
|
|
21
|
+
knowledge: '/api/grove/knowledge',
|
|
22
|
+
works: '/api/grove/works',
|
|
23
|
+
reports: '/api/grove/reports',
|
|
24
|
+
parsers: '/api/grove/parsers',
|
|
25
|
+
sources: '/api/grove/sources',
|
|
26
|
+
research: '/api/grove/research',
|
|
27
|
+
inspirations: '/api/grove/inspirations',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Largest body the panel may post, matching the proposal limit. */
|
|
31
|
+
const maxBody = 128 * 1024
|
|
32
|
+
|
|
33
|
+
function coded(code, message) {
|
|
34
|
+
return Object.assign(new Error(message), { code })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function json(body, status = 200) {
|
|
38
|
+
return new Response(JSON.stringify(body), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statusOf(error) {
|
|
45
|
+
switch (error.code) {
|
|
46
|
+
case 'invalid-path':
|
|
47
|
+
case 'not-directory':
|
|
48
|
+
case 'symlink':
|
|
49
|
+
case 'invalid-body':
|
|
50
|
+
return 400
|
|
51
|
+
case 'not-found':
|
|
52
|
+
case 'ENOENT':
|
|
53
|
+
return 404
|
|
54
|
+
case 'not-configured':
|
|
55
|
+
return 409
|
|
56
|
+
case 'too-large':
|
|
57
|
+
return 413
|
|
58
|
+
default:
|
|
59
|
+
return 500
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function failure(error) {
|
|
64
|
+
const code = typeof error?.code === 'string' ? error.code : 'internal'
|
|
65
|
+
return json({ error: { code, message: error instanceof Error ? error.message : String(error) } }, statusOf({ code }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate a folder a person chose for the vault. Rejects relative paths and symlinked directories. */
|
|
69
|
+
export async function inspectVault(value) {
|
|
70
|
+
if (typeof value !== 'string' || value.trim() === '') throw coded('invalid-path', 'Choose a folder')
|
|
71
|
+
if (!isAbsolute(value.trim())) throw coded('invalid-path', 'The vault path must be absolute')
|
|
72
|
+
const path = resolve(value.trim())
|
|
73
|
+
const info = await stat(path).catch(() => null)
|
|
74
|
+
if (info === null || !info.isDirectory()) throw coded('not-directory', 'The vault path is not a directory')
|
|
75
|
+
if (await realpath(path) !== path) throw coded('symlink', 'The vault path must be a real directory, not a symlink')
|
|
76
|
+
const looksLikeVault = await stat(join(path, '.obsidian')).then(entry => entry.isDirectory(), () => false)
|
|
77
|
+
return { path, looksLikeVault }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function checkConnection(value) {
|
|
81
|
+
const candidate = await inspectVault(value)
|
|
82
|
+
const grove = await new Vault(candidate.path).folder().catch(error => {
|
|
83
|
+
if (error.code === 'ENOENT') return candidate.path
|
|
84
|
+
throw coded('invalid-path', 'The Grove directory is not usable')
|
|
85
|
+
})
|
|
86
|
+
await access(grove, constants.R_OK | constants.W_OK | constants.X_OK).catch(() => {
|
|
87
|
+
throw coded('invalid-path', 'The Grove directory must be readable and writable')
|
|
88
|
+
})
|
|
89
|
+
return candidate
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Metadata the panel needs to choose between the cold-start and overview states. Never returns note bodies. */
|
|
93
|
+
export async function vaultStatus(path, source) {
|
|
94
|
+
const empty = {
|
|
95
|
+
configured: false, source: null, vault: null, usable: false, writable: false,
|
|
96
|
+
looksLikeVault: false, counts: { total: 0 }, recent: [],
|
|
97
|
+
}
|
|
98
|
+
if (!path) return empty
|
|
99
|
+
let usable = false
|
|
100
|
+
try {
|
|
101
|
+
usable = (await stat(path)).isDirectory() && await realpath(path) === path
|
|
102
|
+
} catch {
|
|
103
|
+
usable = false
|
|
104
|
+
}
|
|
105
|
+
if (!usable) return { ...empty, configured: true, source, vault: path }
|
|
106
|
+
const writable = await access(path, constants.W_OK).then(() => true, () => false)
|
|
107
|
+
const looksLikeVault = await stat(join(path, '.obsidian')).then(entry => entry.isDirectory(), () => false)
|
|
108
|
+
const vault = new Vault(path)
|
|
109
|
+
const inventory = await vault.inventory().catch(() => new Map())
|
|
110
|
+
const latest = [...inventory].map(([id, versions]) => [id, Math.max(...versions)])
|
|
111
|
+
const notes = []
|
|
112
|
+
for (const [id, version] of latest.slice(0, 300)) {
|
|
113
|
+
try {
|
|
114
|
+
notes.push((await vault.get(id, version)).meta)
|
|
115
|
+
} catch {
|
|
116
|
+
// A note that no longer parses is skipped; the overview still opens.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const counts = { total: latest.length }
|
|
120
|
+
for (const meta of notes) counts[meta.type] = (counts[meta.type] ?? 0) + 1
|
|
121
|
+
const recent = notes
|
|
122
|
+
.sort((a, b) => String(b.modified ?? b.created ?? '').localeCompare(String(a.modified ?? a.created ?? '')))
|
|
123
|
+
.slice(0, 6)
|
|
124
|
+
.map(({ id, title, type, version, status, modified, created }) => ({
|
|
125
|
+
id, title, type, version, ...(status === undefined ? {} : { status }), at: modified ?? created ?? null,
|
|
126
|
+
}))
|
|
127
|
+
return {
|
|
128
|
+
configured: true, source, vault: path, usable, writable, looksLikeVault,
|
|
129
|
+
counts, recent, sampled: latest.length > notes.length,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Create the first note from the panel. Only inspirations: research and evidence stay in the terminal. */
|
|
134
|
+
export async function capture(vaultPath, input) {
|
|
135
|
+
if (!vaultPath) throw coded('not-configured', 'Grove has no vault yet')
|
|
136
|
+
const body = input?.body
|
|
137
|
+
if (typeof body !== 'string' || body.trim() === '') throw coded('invalid-body', 'Write something first')
|
|
138
|
+
if (Buffer.byteLength(body) > maxBody) throw coded('too-large', 'The note is too large')
|
|
139
|
+
const vault = new Vault(vaultPath)
|
|
140
|
+
const note = await captureInspiration(vault, { body, title: input?.title })
|
|
141
|
+
const folder = await vault.folder()
|
|
142
|
+
return {
|
|
143
|
+
id: note.meta.id, type: note.meta.type, title: note.meta.title, version: 1,
|
|
144
|
+
path: join(folder, `${note.meta.id}.v1.md`),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Persist the chosen vault into this plugin row's configuration. */
|
|
149
|
+
export function saveVault(ctx, path) {
|
|
150
|
+
const entry = ctx.fiber?.entry
|
|
151
|
+
const editor = ctx.get?.('configEditor')
|
|
152
|
+
if (!entry || !editor || typeof editor.edit !== 'function') {
|
|
153
|
+
return Promise.reject(coded('save-unavailable', 'This profile cannot save plugin configuration'))
|
|
154
|
+
}
|
|
155
|
+
return editor.edit(entry, current => ({ ...current, vault: path }))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readJson(request) {
|
|
159
|
+
try {
|
|
160
|
+
return await request.json()
|
|
161
|
+
} catch {
|
|
162
|
+
throw coded('invalid-body', 'Expected a JSON body')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register the panel's same-origin routes. They stay available before a vault is configured,
|
|
168
|
+
* so the page can run its cold start.
|
|
169
|
+
* @param ctx - the plugin context.
|
|
170
|
+
* @param options - current vault path/source getter.
|
|
171
|
+
*/
|
|
172
|
+
export function registerRoutes(ctx, options) {
|
|
173
|
+
const delay = Number.isFinite(options.saveDelay) ? options.saveDelay : 100
|
|
174
|
+
ctx.inject(['connection'], (scoped) => {
|
|
175
|
+
const connection = scoped.get('connection')
|
|
176
|
+
if (!connection?.fetch || typeof connection.fetch.register !== 'function') return
|
|
177
|
+
const route = (path, methods, fetch) => {
|
|
178
|
+
scoped.effect(
|
|
179
|
+
() => connection.fetch.register({ path, methods, requestBody: 'buffered', fetch }),
|
|
180
|
+
`grove: ${path}`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
route(routes.status, ['GET'], async () => {
|
|
184
|
+
const { path, source } = options.current()
|
|
185
|
+
return json(await vaultStatus(path, source))
|
|
186
|
+
})
|
|
187
|
+
route(routes.capture, ['POST'], async request => {
|
|
188
|
+
try {
|
|
189
|
+
const body = await readJson(request)
|
|
190
|
+
return json(await capture(options.current().path, body))
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return failure(error)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
route(routes.inspirations, ['POST'], async request => {
|
|
196
|
+
try {
|
|
197
|
+
const input = await readJson(request)
|
|
198
|
+
const { path } = options.current()
|
|
199
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
200
|
+
const vault = new Vault(path)
|
|
201
|
+
if (input?.id !== undefined) {
|
|
202
|
+
if (typeof input.id !== 'string' || !/^[a-f0-9-]{36}$/.test(input.id) ||
|
|
203
|
+
(input.version !== undefined && (!Number.isSafeInteger(input.version) || input.version < 1))) {
|
|
204
|
+
throw coded('invalid-body', 'Invalid inspiration reference')
|
|
205
|
+
}
|
|
206
|
+
return json(await readInspiration(vault, input.id, input.version))
|
|
207
|
+
}
|
|
208
|
+
const query = typeof input?.query === 'string' ? input.query.trim() : ''
|
|
209
|
+
return json({ notes: (await findInspirations(vault, query)).slice(0, 50) })
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return failure(error)
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
for (const [path, list, read, save] of [
|
|
215
|
+
[routes.knowledge, listKnowledge, readKnowledge, saveKnowledge],
|
|
216
|
+
[routes.works, listWorks, readWork, saveWork],
|
|
217
|
+
]) {
|
|
218
|
+
route(path, ['POST'], async request => {
|
|
219
|
+
try {
|
|
220
|
+
const { path } = options.current()
|
|
221
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
222
|
+
const store = new Vault(path)
|
|
223
|
+
const input = await readJson(request)
|
|
224
|
+
if (input?.action === 'list') return json({ items: await list(store, typeof input.query === 'string' ? input.query : '') })
|
|
225
|
+
if (input?.action === 'read') return json(await read(store, input.id, input.version))
|
|
226
|
+
if (input?.action === 'save' && typeof input.body === 'string' && input.body.trim()) return json(await save(store, input))
|
|
227
|
+
throw coded('invalid-body', 'Choose an action and supply a body when saving')
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return failure(error)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
route(routes.model, ['GET', 'POST'], async request => {
|
|
234
|
+
if (request.method === 'GET') return json({ enabled: options.modelConfig?.enabled === true, endpoint: options.modelConfig?.endpoint, name: options.modelConfig?.name })
|
|
235
|
+
try {
|
|
236
|
+
const input = await readJson(request)
|
|
237
|
+
const model = input?.enabled === false ? { enabled: false } :
|
|
238
|
+
{ enabled: true, endpoint: input?.endpoint, name: input?.name }
|
|
239
|
+
if (model.enabled) {
|
|
240
|
+
if (typeof model.endpoint !== 'string' || typeof model.name !== 'string' || !model.name.trim() ||
|
|
241
|
+
model.name.length > 200 || model.endpoint.length > 2048) throw coded('invalid-body', 'Enter the model endpoint and name')
|
|
242
|
+
const url = new URL(model.endpoint)
|
|
243
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
|
244
|
+
throw coded('invalid-body', 'Use an HTTPS endpoint without embedded credentials')
|
|
245
|
+
}
|
|
246
|
+
if (input.test === true) {
|
|
247
|
+
const provider = options.model?.(model)
|
|
248
|
+
if (!provider) throw coded('invalid-body', 'Set GROVE_MODEL_KEY for the host')
|
|
249
|
+
const reply = await provider.investigate({ question: 'Confirm the connection. Return JSON.', materials: [], history: [] })
|
|
250
|
+
if (typeof reply?.text !== 'string' || !Array.isArray(reply.citations) || reply.citations.length) {
|
|
251
|
+
throw coded('invalid-body', 'The model did not return the expected format')
|
|
252
|
+
}
|
|
253
|
+
return json({ ok: true })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const editor = ctx.get?.('configEditor')
|
|
257
|
+
const entry = ctx.fiber?.entry
|
|
258
|
+
if (!entry || typeof editor?.edit !== 'function') throw coded('save-unavailable', 'This profile cannot save model settings')
|
|
259
|
+
setTimeout(() => {
|
|
260
|
+
editor.edit(entry, current => ({ ...current, model }))
|
|
261
|
+
.catch(error => console.warn(`[grove] could not save model settings: ${error.message}`))
|
|
262
|
+
}, delay)
|
|
263
|
+
return json({ pending: true })
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return failure(error)
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
route(routes.findings, ['POST'], async request => {
|
|
269
|
+
try {
|
|
270
|
+
const { path } = options.current()
|
|
271
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
272
|
+
const store = new Vault(path)
|
|
273
|
+
const input = await readJson(request)
|
|
274
|
+
const findings = (await store.list()).filter(({ meta }) => meta.type === 'finding' &&
|
|
275
|
+
meta.links?.some(link => link.kind === 'related' && link.id === input?.id))
|
|
276
|
+
if (input?.action === 'list') return json({ findings })
|
|
277
|
+
const question = typeof input?.question === 'string' ? input.question.trim() : ''
|
|
278
|
+
if (!question || question.length > 1000 || !Array.isArray(input.materials) || input.materials.length > 20 ||
|
|
279
|
+
!Number.isSafeInteger(input.version) || input.version < 1) throw coded('invalid-body', 'Enter a research question and select evidence')
|
|
280
|
+
const provider = options.modelConfig?.enabled ? options.model?.(options.modelConfig) : null
|
|
281
|
+
if (!provider) throw coded('not-configured', 'Configure and enable a model first')
|
|
282
|
+
return json(await runResearchModel(store, provider, { ...input, question, history: input.includeHistory === true ? findings.map(({ meta, body }) => ({ question: meta.title, text: body })).slice(-8) : [] }))
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return failure(error)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
route(routes.parsers, ['GET', 'POST'], async request => {
|
|
288
|
+
if (request.method === 'GET') return json({ pdf: options.pdfEnabled !== false })
|
|
289
|
+
try {
|
|
290
|
+
const input = await readJson(request)
|
|
291
|
+
if (typeof input?.pdf !== 'boolean') throw coded('invalid-body', 'Choose whether PDF parsing is enabled')
|
|
292
|
+
const editor = ctx.get?.('configEditor')
|
|
293
|
+
const entry = ctx.fiber?.entry
|
|
294
|
+
if (!entry || typeof editor?.edit !== 'function') throw coded('save-unavailable', 'This profile cannot save parser settings')
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
editor.edit(entry, current => ({ ...current, parsers: { ...current.parsers, pdf: input.pdf } }))
|
|
297
|
+
.catch(error => console.warn(`[grove] could not save parser settings: ${error.message}`))
|
|
298
|
+
}, delay)
|
|
299
|
+
return json({ pending: true })
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return failure(error)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
route(routes.reports, ['POST'], async request => {
|
|
305
|
+
try {
|
|
306
|
+
const { path } = options.current()
|
|
307
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
308
|
+
const store = new Vault(path)
|
|
309
|
+
const input = await readJson(request)
|
|
310
|
+
if (input?.action === 'list') {
|
|
311
|
+
const notes = await store.list()
|
|
312
|
+
const sources = new Set(notes.filter(({ meta }) => meta.type === 'source' &&
|
|
313
|
+
meta.links?.some(link => link.kind === 'related' && link.id === input.research)).map(({ meta }) => meta.id))
|
|
314
|
+
const evidence = notes.filter(({ meta }) => meta.type === 'evidence' &&
|
|
315
|
+
meta.links?.some(link => link.kind === 'derived' && sources.has(link.id))).map(({ meta }) => meta)
|
|
316
|
+
return json({ reports: await listReports(store, input.research), evidence })
|
|
317
|
+
}
|
|
318
|
+
if (input?.action === 'draft' && Number.isSafeInteger(input.version) && input.version > 0) {
|
|
319
|
+
return json(await draftFromFinding(store, input.research, input.id, input.version))
|
|
320
|
+
}
|
|
321
|
+
if (input?.action === 'read') return json(await readReport(store, input.id, input.version))
|
|
322
|
+
if (input?.action === 'confirm' && typeof input.body === 'string' && input.body.trim() &&
|
|
323
|
+
(input.evidence === undefined || Array.isArray(input.evidence))) {
|
|
324
|
+
return json(await confirmReport(store, input))
|
|
325
|
+
}
|
|
326
|
+
throw coded('invalid-body', 'Choose a report action and nonempty report body')
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return failure(error)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
route(routes.sources, ['POST'], async request => {
|
|
332
|
+
try {
|
|
333
|
+
const { path } = options.current()
|
|
334
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
335
|
+
const vault = new Vault(path)
|
|
336
|
+
const input = await readJson(request)
|
|
337
|
+
if (input?.action === 'import') {
|
|
338
|
+
const name = input.name
|
|
339
|
+
const original = input.original
|
|
340
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 256 ||
|
|
341
|
+
typeof original !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(original)) {
|
|
342
|
+
throw coded('invalid-body', 'Choose a Markdown or PDF file')
|
|
343
|
+
}
|
|
344
|
+
if (original.length > 700000) throw coded('too-large', 'File exceeds the import limit')
|
|
345
|
+
return json(await importSource(vault, options.parsers ?? new Map(), {
|
|
346
|
+
name, original, research: input.research, bytes: Buffer.from(original, 'base64'),
|
|
347
|
+
}))
|
|
348
|
+
}
|
|
349
|
+
if (input?.action === 'excerpt') {
|
|
350
|
+
return json(await saveEvidence(vault, { id: input.id, version: input.version }, input.index))
|
|
351
|
+
}
|
|
352
|
+
if (input?.action === 'open' && Number.isSafeInteger(input.version) && input.version > 0) {
|
|
353
|
+
return json(await openSource(vault, input.id, input.version))
|
|
354
|
+
}
|
|
355
|
+
if (input?.action === 'list') {
|
|
356
|
+
const notes = await vault.list()
|
|
357
|
+
return json({ sources: notes.filter(({ meta }) => meta.type === 'source' &&
|
|
358
|
+
meta.links?.some(link => link.kind === 'related' && link.id === input.research)).map(({ meta }) => meta) })
|
|
359
|
+
}
|
|
360
|
+
throw coded('invalid-body', 'Choose a source action')
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return failure(error)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
route(routes.research, ['GET', 'POST'], async request => {
|
|
366
|
+
try {
|
|
367
|
+
const { path } = options.current()
|
|
368
|
+
if (!path) throw coded('not-configured', 'Grove has no vault yet')
|
|
369
|
+
const vault = new Vault(path)
|
|
370
|
+
if (request.method === 'GET') return json({ studies: await listResearch(vault) })
|
|
371
|
+
const input = await readJson(request)
|
|
372
|
+
const body = typeof input?.body === 'string' ? input.body.trim() : ''
|
|
373
|
+
if (!body) throw coded('invalid-body', 'Write down your research notes')
|
|
374
|
+
if (input.id !== undefined) {
|
|
375
|
+
if (typeof input.id !== 'string' || !Number.isSafeInteger(input.version) || input.version < 1) {
|
|
376
|
+
throw coded('invalid-body', 'Invalid research version')
|
|
377
|
+
}
|
|
378
|
+
return json(await saveResearch(vault, input.id, input.version, body))
|
|
379
|
+
}
|
|
380
|
+
const question = typeof input.question === 'string' ? input.question.trim() : ''
|
|
381
|
+
if (!question || question.length > 300) throw coded('invalid-body', 'Enter a research question up to 300 characters')
|
|
382
|
+
const origin = input.origin ? (await readKnowledge(vault, input.origin.id, input.origin.version)).meta : undefined
|
|
383
|
+
return json(await startResearch(vault, question, body, origin))
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return failure(error)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
route(routes.connection, ['POST'], async request => {
|
|
389
|
+
try {
|
|
390
|
+
return json(await checkConnection((await readJson(request))?.path))
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return failure(error)
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
route(routes.config, ['POST'], async request => {
|
|
396
|
+
let path
|
|
397
|
+
try {
|
|
398
|
+
path = (await checkConnection((await readJson(request))?.path)).path
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return failure(error)
|
|
401
|
+
}
|
|
402
|
+
// Answer before the configuration reload replaces this plugin instance, otherwise the
|
|
403
|
+
// response can be cut halfway. The page polls status until the new vault is live.
|
|
404
|
+
setTimeout(() => {
|
|
405
|
+
saveVault(ctx, path).catch(error => {
|
|
406
|
+
console.warn(`[grove] could not save the vault path: ${error.message}`)
|
|
407
|
+
})
|
|
408
|
+
}, delay)
|
|
409
|
+
return json({ ok: true, pending: true, vault: path })
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -4,7 +4,7 @@ import { lstat, realpath, mkdir, open, link, unlink, readdir } from 'node:fs/pro
|
|
|
4
4
|
import { isAbsolute, join } from 'node:path'
|
|
5
5
|
import { parse, stringify } from 'yaml'
|
|
6
6
|
|
|
7
|
-
export const types = ['inspiration', 'question', 'concept', 'theory', 'claim', 'evidence', 'method', 'output', 'exploration', 'research']
|
|
7
|
+
export const types = ['inspiration', 'question', 'concept', 'theory', 'claim', 'evidence', 'method', 'output', 'exploration', 'research', 'source', 'report', 'finding']
|
|
8
8
|
export const stages = ['question', 'framework', 'evidence', 'analysis', 'judgment', 'validation', 'expression', 'review']
|
|
9
9
|
const relations = ['related', 'derived', 'supports', 'refutes', 'qualifies']
|
|
10
10
|
const validId = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/
|
|
@@ -94,6 +94,10 @@ export class Vault {
|
|
|
94
94
|
return entries
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
async versions(id) {
|
|
98
|
+
return ((await this.inventory()).get(id) ?? []).sort((a, b) => b - a)
|
|
99
|
+
}
|
|
100
|
+
|
|
97
101
|
async get(id, version) {
|
|
98
102
|
if (typeof id !== 'string' || !validId.test(id)) throw new Error('Invalid Grove ID')
|
|
99
103
|
const selected = version ?? Math.max(...((await this.inventory()).get(id) ?? []))
|
|
@@ -127,11 +131,20 @@ export class Vault {
|
|
|
127
131
|
meta.source = required(input.source, 'source')
|
|
128
132
|
meta.locator = required(input.locator, 'locator')
|
|
129
133
|
}
|
|
134
|
+
if (input.links !== undefined) meta.links = await this.resolveLinks(input.links)
|
|
130
135
|
const path = await this.folder(true)
|
|
131
136
|
await publish(path, join(path, `${id}.v1.md`), encode(meta, body))
|
|
132
137
|
return { meta, body }
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
async resolveLinks(links) {
|
|
141
|
+
return Promise.all(links.map(async link => {
|
|
142
|
+
if (!relations.includes(link.kind)) throw new Error('Invalid relationship')
|
|
143
|
+
const target = await this.get(link.id, link.version)
|
|
144
|
+
return { kind: link.kind, id: target.meta.id, version: target.meta.version }
|
|
145
|
+
}))
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
async update(id, expected, change) {
|
|
136
149
|
const note = await this.get(id)
|
|
137
150
|
if (note.meta.version !== expected) throw new Error('Version conflict; read the latest note and retry')
|
|
@@ -153,6 +166,7 @@ export class Vault {
|
|
|
153
166
|
if (meta.type !== 'research' || !stages.includes(change.stage)) throw new Error('Invalid research stage')
|
|
154
167
|
meta.stage = change.stage
|
|
155
168
|
}
|
|
169
|
+
if (change.links !== undefined) meta.links = await this.resolveLinks(change.links)
|
|
156
170
|
if (change.link) {
|
|
157
171
|
const { kind, id: targetId, version } = change.link
|
|
158
172
|
if (!relations.includes(kind)) throw new Error('Invalid relationship')
|
|
@@ -166,6 +180,7 @@ export class Vault {
|
|
|
166
180
|
meta.links = [...(meta.links ?? []), edge]
|
|
167
181
|
}
|
|
168
182
|
}
|
|
183
|
+
if (change.revisionReason !== undefined) meta.revisionReason = required(change.revisionReason, 'revision reason')
|
|
169
184
|
meta.version++
|
|
170
185
|
meta.modified = new Date().toISOString()
|
|
171
186
|
const path = await this.folder()
|