dsh-grove 0.2.0 → 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/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.create({ type: 'inspiration', body, title: input?.title })
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 inspectVault((await readJson(request))?.path)).path
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()