@theronap/agnoclast-mcp 0.9.265 → 0.9.267

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.
@@ -5,7 +5,7 @@
5
5
  // that web/lib/engine/release.test.ts pins to the server's registry.
6
6
 
7
7
  /** Tools any release flag gates — hidden when the server cannot be asked. Mirror of allGatedTools(). */
8
- export const GATED_FALLBACK = ['intake_shadow', 'my_messages', 'read_conversation', 'send_message', 'set_manager']
8
+ export const GATED_FALLBACK = ['attach_document', 'document_diff', 'get_document', 'intake_shadow', 'list_documents', 'my_messages', 'pin_document', 'read_conversation', 'read_document', 'send_message', 'set_manager', 'upload_document_version']
9
9
 
10
10
  export const RELEASE_FETCH_TIMEOUT_MS = 2500
11
11
 
package/lib/server.mjs CHANGED
@@ -2323,10 +2323,12 @@ function renderNudge(payload) {
2323
2323
  // everything attached to it.
2324
2324
  //
2325
2325
  // The honest condition is: nothing to show at all.
2326
- if (!t.identifiers?.length && !t.events?.length) {
2326
+ if (!t.identifiers?.length && !t.events?.length && !t.headerNotes?.length) {
2327
2327
  return { content: [{ type: 'text', text: `"${name}" has no history yet — no identifier stamps and nothing attached. Stamp its page with [[repo:owner/name]] (what it identifies), or attach records to it, and events will accrue here.` }] }
2328
2328
  }
2329
2329
  const lines = [`# ${name} — node timeline (history; the page is the present)`]
2330
+ // ADR-0096: pinned files head the timeline. Composed server-side (pageFileNotes), printed verbatim.
2331
+ for (const n of t.headerNotes ?? []) lines.push(n)
2330
2332
  if (t.identifiers?.length) {
2331
2333
  lines.push(`Joins: ${t.identifiers.map((i) => `[[${i.id}]] · ${i.visibleCount} visible`).join(' | ')}${t.incomplete ? ' (partial — one join failed to read)' : ''}`)
2332
2334
  } else {
@@ -7271,6 +7273,215 @@ function renderNudge(payload) {
7271
7273
  },
7272
7274
  )
7273
7275
 
7276
+ // ── Stored documents (ADR-0096): files kept in Agnoclast on a page, versioned, readable as text ──
7277
+ server.registerTool(
7278
+ 'pin_document',
7279
+ {
7280
+ title: 'Pin a stored file to the top of its page',
7281
+ description: 'Pin (or unpin) a stored file so it heads its page and the page\'s timeline — for the file a page is really about, like the accounting workbook on an "Accounting records" page. Needs edit access to the page. Get file_id from the 📌/📎 lines read_page prints at the top of a page.',
7282
+ inputSchema: {
7283
+ file_id: z.string().describe('the stored file\'s id (read_page prints it as "id …")'),
7284
+ pinned: z.boolean().optional().describe('false to unpin; default true'),
7285
+ },
7286
+ },
7287
+ async ({ file_id, pinned }) => {
7288
+ let res
7289
+ try {
7290
+ res = await fetchCortex(`${BASE}/api/stored-files/${encodeURIComponent(file_id)}/pin`, {
7291
+ method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
7292
+ body: JSON.stringify({ pinned: pinned !== false }),
7293
+ })
7294
+ } catch (e) { return toolError(`Could not pin: ${e.message}`) }
7295
+ const out = await res.json().catch(() => null)
7296
+ if (!res.ok) return toolError(`Could not ${pinned === false ? 'unpin' : 'pin'}: ${out?.detail ?? out?.error ?? res.status}`)
7297
+ const t = out.file?.title ?? file_id
7298
+ if (!out.changed) return { content: [{ type: 'text', text: `"${t}" was already ${pinned === false ? 'unpinned' : 'pinned'}.` }] }
7299
+ return { content: [{ type: 'text', text: pinned === false ? `Unpinned "${t}".` : `Pinned "${t}" — it now heads its page and the page's timeline.` }] }
7300
+ },
7301
+ )
7302
+
7303
+ const docFail = async (verb, res) => {
7304
+ const out = await res.json().catch(() => null)
7305
+ return toolError(`Could not ${verb}: ${out?.detail ?? out?.error ?? res.status}`)
7306
+ }
7307
+
7308
+ server.registerTool(
7309
+ 'list_documents',
7310
+ {
7311
+ title: 'List the stored files on a page',
7312
+ description: 'The files kept in Agnoclast on a page (a spreadsheet, a Word document, a CSV…), pinned first, with each one\'s id, current version and whether it can be read as text. read_page also names them at the top of the page. Then read_document to read one, document_diff to see what changed, get_document to download it.',
7313
+ inputSchema: { page: z.string().describe('the page: its exact title, its `ref:` from read_page, or a document id') },
7314
+ },
7315
+ async ({ page }) => {
7316
+ let res
7317
+ try { res = await fetchCortex(`${BASE}/api/stored-files?${new URLSearchParams({ page })}`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
7318
+ catch (e) { return toolError(`Could not list files: ${e.message}`) }
7319
+ if (!res.ok) return docFail('list files', res)
7320
+ const out = await res.json()
7321
+ if (!out.files?.length) return { content: [{ type: 'text', text: `No stored files on "${out.page?.title ?? page}".` }] }
7322
+ const lines = [`Stored files on "${out.page?.title ?? page}":`]
7323
+ for (const f of out.files) {
7324
+ const v = f.versions?.[0]
7325
+ lines.push(`${f.pinned ? '📌' : '-'} "${f.title}" — id ${f.id} · v${f.latestVersion}${v ? ` · ${v.filename} · ${Math.round(v.sizeBytes / 1024)} KB · ${String(v.createdAt).slice(0, 10)}${v.readable === 'ok' ? '' : ' · not readable as text'}` : ''}`)
7326
+ }
7327
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
7328
+ },
7329
+ )
7330
+
7331
+ server.registerTool(
7332
+ 'read_document',
7333
+ {
7334
+ title: 'Read a stored file as text',
7335
+ description: 'Read a file kept in Agnoclast (Excel, Word, CSV, plain text) as text. A workbook reads as one table per sheet, then each formula with its value — a formula marked "⚠ not calculated" has no value yet (the file was saved without recalculating), which is NOT zero. Long files come in pages: pass the returned offset to continue. Pass `sheet` to read one sheet, `version` for an older version, format "cells" for one line per cell.',
7336
+ inputSchema: {
7337
+ file_id: z.string().describe('the stored file\'s id (from read_page\'s 📌/📎 lines or list_documents)'),
7338
+ version: z.number().int().positive().optional().describe('a version number; default the latest'),
7339
+ sheet: z.string().optional().describe('a workbook: read only this sheet'),
7340
+ format: z.enum(['grid', 'cells']).optional().describe('a workbook: "grid" (default) or "cells" — one line per cell, Sheet!A1<TAB>value<TAB>=formula'),
7341
+ offset: z.number().int().min(0).optional().describe('continue from here (the previous call says where)'),
7342
+ },
7343
+ },
7344
+ async ({ file_id, version, sheet, format, offset }) => {
7345
+ const q = new URLSearchParams()
7346
+ if (version) q.set('version', String(version))
7347
+ if (sheet) q.set('sheet', sheet)
7348
+ if (format) q.set('format', format)
7349
+ if (offset) q.set('offset', String(offset))
7350
+ let res
7351
+ try { res = await fetchCortex(`${BASE}/api/stored-files/${encodeURIComponent(file_id)}/text?${q}`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
7352
+ catch (e) { return toolError(`Could not read the file: ${e.message}`) }
7353
+ if (!res.ok) return docFail('read the file', res)
7354
+ const o = await res.json()
7355
+ const head = [`# ${o.title} — ${o.filename}, v${o.version}${o.version !== o.latestVersion ? ` (latest is v${o.latestVersion})` : ''}`]
7356
+ if (o.sheets) head.push(`Sheets: ${o.sheets.map((n) => `"${n}"`).join(', ')}`)
7357
+ if (o.note) head.push(o.note)
7358
+ if (o.offset) head.push(`(continuing from character ${o.offset} of ${o.total})`)
7359
+ const tail = o.nextOffset != null
7360
+ ? `\n\n— ${o.total - o.nextOffset} more characters. Continue with read_document file_id:"${o.id}" offset:${o.nextOffset}${version ? ` version:${version}` : ''}${sheet ? ` sheet:"${sheet}"` : ''}.`
7361
+ : ''
7362
+ return { content: [{ type: 'text', text: `${head.join('\n')}\n\n${o.text}${tail}` }] }
7363
+ },
7364
+ )
7365
+
7366
+ server.registerTool(
7367
+ 'document_diff',
7368
+ {
7369
+ title: 'What changed in a stored file',
7370
+ description: 'Compare two versions of a stored file — by default the latest against the one before. A workbook is compared cell by cell (`~ Invoices!D21: "1,200" → "1,450"`), a document paragraph by paragraph. The first line says how big the change is, so you can tell a small edit from a rewrite. Use it when read_page warns that a file changed after the page was written, then decide whether what the page says about the file is still true — and fix the page with edit_page if not.',
7371
+ inputSchema: {
7372
+ file_id: z.string().describe('the stored file\'s id'),
7373
+ from: z.number().int().positive().optional().describe('older version; default the one before `to`'),
7374
+ to: z.number().int().positive().optional().describe('newer version; default the latest'),
7375
+ },
7376
+ },
7377
+ async ({ file_id, from, to }) => {
7378
+ const q = new URLSearchParams()
7379
+ if (from) q.set('from', String(from))
7380
+ if (to) q.set('to', String(to))
7381
+ let res
7382
+ try { res = await fetchCortex(`${BASE}/api/stored-files/${encodeURIComponent(file_id)}/diff?${q}`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
7383
+ catch (e) { return toolError(`Could not compare versions: ${e.message}`) }
7384
+ if (!res.ok) return docFail('compare versions', res)
7385
+ const o = await res.json()
7386
+ const lines = [`# ${o.title}: v${o.from} (${String(o.fromDate).slice(0, 10)}) → v${o.to} (${String(o.toDate).slice(0, 10)})`, `${o.magnitude === 'large' ? 'LARGE change' : o.magnitude === 'small' ? 'Small change' : 'No change'}: ${o.summary}`]
7387
+ if (o.lines?.length) lines.push('', ...o.lines)
7388
+ if (o.truncated) lines.push(`… and ${o.truncated} more changes not shown.`)
7389
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
7390
+ },
7391
+ )
7392
+
7393
+ server.registerTool(
7394
+ 'get_document',
7395
+ {
7396
+ title: 'Download a stored file',
7397
+ description: 'Download the original of a file kept in Agnoclast (the actual .xlsx/.docx/…) to ~/Downloads/cortex/ and return its local path — for when you need the real file, e.g. to open it with a spreadsheet library. To just read it, read_document is faster.',
7398
+ inputSchema: {
7399
+ file_id: z.string().describe('the stored file\'s id'),
7400
+ version: z.number().int().positive().optional().describe('a version number; default the latest'),
7401
+ },
7402
+ },
7403
+ async ({ file_id, version }) => {
7404
+ let res
7405
+ try { res = await fetchCortex(`${BASE}/api/stored-files/${encodeURIComponent(file_id)}/download${version ? `?version=${version}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
7406
+ catch (e) { return toolError(`Could not download: ${e.message}`) }
7407
+ if (!res.ok) return docFail('download', res)
7408
+ const buf = Buffer.from(await res.arrayBuffer())
7409
+ const m = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')
7410
+ const name = (m ? m[1] : `agnoclast-file-${file_id}`).replace(/[^\w.\- ]/g, '_')
7411
+ const dir = join(homedir(), 'Downloads', 'cortex')
7412
+ mkdirSync(dir, { recursive: true })
7413
+ const path = join(dir, name)
7414
+ writeFileSync(path, buf)
7415
+ return { content: [{ type: 'text', text: `Saved v${res.headers.get('x-stored-file-version') ?? '?'} of the stored file to ${path} (${buf.length} bytes).` }] }
7416
+ },
7417
+ )
7418
+
7419
+ // UPLOADS (slice 4). The server refuses an assistant unless the person turned on "Let assistants upload
7420
+ // files" in Agnoclast → Settings — a switch only the console can flip. These tools never ask for it.
7421
+ // Bytes go straight to storage on the signed URL the server hands out, then the server reads them back.
7422
+ const uploadStored = async (target, path, extra = {}) => {
7423
+ let st
7424
+ try { st = statSync(path) } catch { return toolError(`No file at ${path}.`) }
7425
+ if (!st.isFile()) return toolError(`${path} is not a file.`)
7426
+ const filename = path.split(/[\\/]/).pop()
7427
+ let res
7428
+ try {
7429
+ res = await fetchCortex(`${BASE}/api/stored-files/uploads`, {
7430
+ method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
7431
+ body: JSON.stringify({ ...target, ...extra, filename, size: st.size }),
7432
+ })
7433
+ } catch (e) { return toolError(`Could not start the upload: ${e.message}`) }
7434
+ const begin = await res.json().catch(() => null)
7435
+ if (!res.ok) return toolError(`Not uploaded: ${begin?.detail ?? begin?.error ?? res.status}`)
7436
+ let put
7437
+ try {
7438
+ put = await fetch(begin.signedUrl, { method: 'PUT', headers: { 'content-type': begin.contentType ?? 'application/octet-stream', 'x-upsert': 'false' }, body: readFileSync(path) })
7439
+ } catch (e) { return toolError(`Not uploaded: storage could not be reached (${e.message}).`) }
7440
+ if (!put.ok) return toolError(`Not uploaded: storage refused the file (${put.status}).`)
7441
+ let commit
7442
+ try {
7443
+ commit = await fetchCortex(`${BASE}/api/stored-files/uploads/${begin.uploadId}/commit`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` } })
7444
+ } catch (e) { return toolError(`Uploaded but not saved: ${e.message}`) }
7445
+ const c = await commit.json().catch(() => null)
7446
+ if (!commit.ok) return toolError(`Uploaded but not saved: ${c?.detail ?? c?.error ?? commit.status}`)
7447
+ const lines = c.unchanged
7448
+ ? [`"${c.title}" is byte-for-byte identical to its current version (v${c.version}); nothing was added.`]
7449
+ : [`Saved "${c.title}" as version ${c.version} (id ${c.fileId}, ${Math.round(c.sizeBytes / 1024)} KB, sha256 ${c.byteSha256.slice(0, 12)}…)${c.pinned ? ', pinned to its page' : ''}.`]
7450
+ if (!c.unchanged && c.contentUnchanged) lines.push('Its contents read the same as the previous version (only the bytes changed).')
7451
+ if (c.extractStatus && c.extractStatus !== 'ok' && c.extractStatus !== 'unchanged') lines.push(`Not readable as text: ${c.extractNote ?? c.extractStatus}.`)
7452
+ else if (c.extractNote) lines.push(c.extractNote)
7453
+ if (!c.unchanged && c.version > 1 && !c.contentUnchanged) lines.push(`See what changed with document_diff file_id:"${c.fileId}", and update the page if what it says about the file is no longer true.`)
7454
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
7455
+ }
7456
+
7457
+ server.registerTool(
7458
+ 'attach_document',
7459
+ {
7460
+ title: 'Store a file on a page',
7461
+ description: 'Keep a local file (Excel, Word, CSV, PDF…) in Agnoclast on a page: it is versioned, readable with read_document, and visible to exactly who can read that page. Needs edit access to the page, and the person must have turned on "Let assistants upload files" in Agnoclast → Settings — if it is off this is refused; tell them, do not work around it. Up to 50 MB. Pass pin:true for the file the page is about.',
7462
+ inputSchema: {
7463
+ page: z.string().describe('the page it lives on: exact title, `ref:` or document id'),
7464
+ path: z.string().describe('absolute path of the local file'),
7465
+ title: z.string().optional().describe('a name for it; default the filename without its extension'),
7466
+ pin: z.boolean().optional().describe('pin it to the top of the page and its timeline'),
7467
+ },
7468
+ },
7469
+ async ({ page, path, title, pin }) => uploadStored({ page }, path, { ...(title ? { title } : {}), ...(pin ? { pin: true } : {}) }),
7470
+ )
7471
+
7472
+ server.registerTool(
7473
+ 'upload_document_version',
7474
+ {
7475
+ title: 'Upload a new version of a stored file',
7476
+ description: 'Upload a local file as the NEW VERSION of a file already stored in Agnoclast. Every earlier version is kept. Identical bytes add nothing. Same permission as attach_document: refused unless the person turned on "Let assistants upload files". Afterwards, check document_diff and update the page if it no longer describes the file.',
7477
+ inputSchema: {
7478
+ file_id: z.string().describe('the stored file\'s id'),
7479
+ path: z.string().describe('absolute path of the local file'),
7480
+ },
7481
+ },
7482
+ async ({ file_id, path }) => uploadStored({ fileId: file_id }, path),
7483
+ )
7484
+
7274
7485
  // send_imessage — ask to send a text from the user's own Mac (ADR-0076). It no longer texts from this
7275
7486
  // machine: it creates a request on Agnoclast that waits on /email → Waiting for the user's OK (or their
7276
7487
  // web send rule approves it), and the user's Mac — the desktop app, through Messages — sends it and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.265",
3
+ "version": "0.9.267",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {