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.
@@ -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.vault ?? process.env.GROVE_VAULT
15
- if (!path) return
16
- const vault = new Vault(path)
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()