dsh-grove 0.2.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +34 -74
- package/README.md +35 -77
- package/client.js +799 -196
- package/package.json +4 -2
- 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 +10 -2
- package/src/proposals.js +1 -1
- package/src/server.js +234 -2
- package/src/vault.js +16 -1
package/src/server.js
CHANGED
|
@@ -2,12 +2,29 @@
|
|
|
2
2
|
import { constants } from 'node:fs'
|
|
3
3
|
import { access, realpath, stat } from 'node:fs/promises'
|
|
4
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'
|
|
5
12
|
import { Vault } from './vault.js'
|
|
6
13
|
|
|
7
14
|
export const routes = {
|
|
8
15
|
status: '/api/grove/status',
|
|
9
16
|
config: '/api/grove/config',
|
|
17
|
+
connection: '/api/grove/connection',
|
|
10
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',
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
/** Largest body the panel may post, matching the proposal limit. */
|
|
@@ -31,6 +48,9 @@ function statusOf(error) {
|
|
|
31
48
|
case 'symlink':
|
|
32
49
|
case 'invalid-body':
|
|
33
50
|
return 400
|
|
51
|
+
case 'not-found':
|
|
52
|
+
case 'ENOENT':
|
|
53
|
+
return 404
|
|
34
54
|
case 'not-configured':
|
|
35
55
|
return 409
|
|
36
56
|
case 'too-large':
|
|
@@ -57,6 +77,18 @@ export async function inspectVault(value) {
|
|
|
57
77
|
return { path, looksLikeVault }
|
|
58
78
|
}
|
|
59
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
|
+
|
|
60
92
|
/** Metadata the panel needs to choose between the cold-start and overview states. Never returns note bodies. */
|
|
61
93
|
export async function vaultStatus(path, source) {
|
|
62
94
|
const empty = {
|
|
@@ -105,7 +137,7 @@ export async function capture(vaultPath, input) {
|
|
|
105
137
|
if (typeof body !== 'string' || body.trim() === '') throw coded('invalid-body', 'Write something first')
|
|
106
138
|
if (Buffer.byteLength(body) > maxBody) throw coded('too-large', 'The note is too large')
|
|
107
139
|
const vault = new Vault(vaultPath)
|
|
108
|
-
const note = await vault
|
|
140
|
+
const note = await captureInspiration(vault, { body, title: input?.title })
|
|
109
141
|
const folder = await vault.folder()
|
|
110
142
|
return {
|
|
111
143
|
id: note.meta.id, type: note.meta.type, title: note.meta.title, version: 1,
|
|
@@ -160,10 +192,210 @@ export function registerRoutes(ctx, options) {
|
|
|
160
192
|
return failure(error)
|
|
161
193
|
}
|
|
162
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
|
+
})
|
|
163
395
|
route(routes.config, ['POST'], async request => {
|
|
164
396
|
let path
|
|
165
397
|
try {
|
|
166
|
-
path = (await
|
|
398
|
+
path = (await checkConnection((await readJson(request))?.path)).path
|
|
167
399
|
} catch (error) {
|
|
168
400
|
return failure(error)
|
|
169
401
|
}
|
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()
|