@coffer-org/server 1.14.0 → 2.1.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { createWriteStream, existsSync } from 'node:fs';
2
+ import { createWriteStream, existsSync, statSync } from 'node:fs';
3
3
  import { realpath } from 'node:fs/promises';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { extname, join, sep } from 'node:path';
@@ -8,17 +8,15 @@ import Fastify from 'fastify';
8
8
  import cors from '@fastify/cors';
9
9
  import multipart from '@fastify/multipart';
10
10
  import fastifyStatic from '@fastify/static';
11
- import { serialize } from '@mikro-orm/core';
12
- import { textSearchKeys, recordTitle, titleKey, storageColumnsFor } from '@coffer-org/sdk/shelf';
13
- import { tokenize } from '@coffer-org/core/search';
14
11
  import { shelfTableName } from "./entity-schema.js";
15
12
  import { getEm, closeDb } from "./db.js";
13
+ import { recordCounts } from "./counts.js";
16
14
  import { ValidationError, NotFoundError } from "./mutate.js";
17
15
  import { resolveEmbed } from "./embed.js";
18
16
  import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
19
17
  import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
20
18
  import { registerPluginsApi } from "./plugins-api.js";
21
- import { pluginHooks, HttpError } from "./plugin-hooks.js";
19
+ import { registerPluginUserApi, registerPluginAdminApi } from "./plugin-user-api.js";
22
20
  import { registerAuthApi, resolveRequestUser, requireAdmin, PUBLIC_API_PATHS } from "./auth-api.js";
23
21
  import { registerMcpHttp } from "./mcp-http.js";
24
22
  import { registerOAuthApi } from "./oauth-api.js";
@@ -26,12 +24,16 @@ import { baseUrl } from "./public-url.js";
26
24
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
27
25
  import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveBaseTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
28
26
  import { buildClientSchema } from "./schema-api.js";
29
- import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, rowMatch, UnknownTypeError } from "./records-api.js";
27
+ import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, } from "./records-api.js";
30
28
  import { maskTree, preserveTree } from "./field-masking.js";
31
29
  import { writePluginSettings } from "./settings-write.js";
32
30
  import { rootLogger, getLogger } from "./log.js";
33
31
  import { uploadsDir } from "./uploads.js";
32
+ import { mimeForName } from "./file-fields.js";
34
33
  import { verifyUploadTicket } from "./upload-ticket.js";
34
+ import { setPluginState } from "./plugin-state.js";
35
+ import { SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY } from "./search-indexer.js";
36
+ import { globalSearch } from "./global-search.js";
35
37
  const ENV_FILE = join(process.cwd(), '.env');
36
38
  if (existsSync(ENV_FILE))
37
39
  process.loadEnvFile(ENV_FILE);
@@ -96,8 +98,11 @@ app.post('/api/upload', async (req, reply) => {
96
98
  return reply.code(400).send({ error: 'no_file' });
97
99
  const ext = extname(data.filename).toLowerCase();
98
100
  const name = `${randomUUID()}${ext}`;
99
- await pipeline(data.file, createWriteStream(join(UPLOADS, name)));
100
- return reply.send({ filename: name });
101
+ const dest = join(UPLOADS, name);
102
+ await pipeline(data.file, createWriteStream(dest));
103
+ const size = statSync(dest).size;
104
+ const mime = mimeForName(name) ?? (data.mimetype || undefined);
105
+ return reply.send({ filename: name, name, ...(mime ? { mime } : {}), size });
101
106
  });
102
107
  app.post('/api/embed', async (req, reply) => {
103
108
  const url = req.body?.url;
@@ -108,8 +113,8 @@ app.post('/api/embed', async (req, reply) => {
108
113
  return reply.code(422).send({ error: 'unresolved' });
109
114
  return reply.send(rec);
110
115
  });
111
- function key(library, type) {
112
- return `${library}/${type}`;
116
+ function key(library, shelf) {
117
+ return `${library}/${shelf}`;
113
118
  }
114
119
  async function guard(reply, fn) {
115
120
  try {
@@ -146,55 +151,30 @@ app.get('/api/libraries', async (req, reply) => {
146
151
  return reply.type('application/json').send(librariesPayload.body);
147
152
  });
148
153
  app.get('/api/counts', async () => {
149
- const fork = getEm().fork();
150
- const out = {};
154
+ const targets = [];
151
155
  for (const [k, mdef] of shelfDefs) {
152
156
  if (mdef.standalone === false)
153
157
  continue;
154
158
  const ename = entityNames.get(k);
155
159
  if (!ename)
156
160
  continue;
157
- out[k] = await fork.count(ename);
161
+ targets.push({ key: k, table: ename });
158
162
  }
159
- return out;
163
+ return recordCounts(targets);
160
164
  });
161
- const SEARCH_SCAN_LIMIT = 1000;
162
- const searchLog = getLogger('search');
163
165
  app.get('/api/search', async (req) => {
164
166
  const { q = '', limit = '20' } = req.query;
165
- const tokens = tokenize(q);
166
- if (tokens.length === 0 || q.trim().length < 2)
167
+ if (q.trim().length < 2)
167
168
  return [];
168
169
  const lim = Math.min(Number(limit) || 20, 100);
169
- const fork = getEm().fork();
170
- const scored = [];
170
+ const shelves = [];
171
171
  for (const [k, mdef] of shelfDefs) {
172
- if (mdef.standalone === false)
173
- continue;
174
- const ename = entityNames.get(k);
175
- if (!ename)
176
- continue;
177
- if (!textSearchKeys(mdef).length)
172
+ const table = entityNames.get(k);
173
+ if (!table)
178
174
  continue;
179
- const [library, type] = k.split('/');
180
- const cols = storageColumnsFor(mdef, [...textSearchKeys(mdef), titleKey(mdef)]);
181
- const rows = (await fork.find(ename, {}, { limit: SEARCH_SCAN_LIMIT, fields: cols })).map((r) => serialize(r));
182
- if (rows.length === SEARCH_SCAN_LIMIT) {
183
- searchLog.warn('scan cap reached', { shelf: k, limit: SEARCH_SCAN_LIMIT });
184
- }
185
- for (const row of rows) {
186
- const m = rowMatch(mdef, row, tokens);
187
- if (!m)
188
- continue;
189
- const label = recordTitle(mdef, row);
190
- scored.push({
191
- score: m.score,
192
- result: { library, type, id: String(row.id), label, snippet: m.snippet },
193
- });
194
- }
175
+ shelves.push({ key: k, table, def: mdef });
195
176
  }
196
- scored.sort((a, b) => b.score - a.score);
197
- return scored.slice(0, lim).map((s) => s.result);
177
+ return globalSearch(shelves, q, lim);
198
178
  });
199
179
  app.patch('/api/plugins/:id', async (req, reply) => {
200
180
  if (!requireAdmin(req, reply))
@@ -228,6 +208,9 @@ app.patch('/api/plugins/:id', async (req, reply) => {
228
208
  fork.create('_Plugin', { id, enabled: enabled ? 1 : 0, updated_at: new Date().toISOString() });
229
209
  }
230
210
  await fork.flush();
211
+ if (enabled) {
212
+ await setPluginState(SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY, '0');
213
+ }
231
214
  return { ok: true, restartRequired: true };
232
215
  });
233
216
  app.delete('/api/plugins/:id/data', async (req, reply) => {
@@ -340,26 +323,8 @@ app.put('/api/plugins/:id/settings', (req, reply) => {
340
323
  return { ok: true, row };
341
324
  });
342
325
  });
343
- app.post('/api/plugins/:id/:action', async (req, reply) => {
344
- if (!requireAdmin(req, reply))
345
- return;
346
- const { id, action } = req.params;
347
- const fn = pluginHooks[id]?.actions?.[action];
348
- if (!fn)
349
- return reply.code(404).send({ error: `unknown action ${id}/${action}` });
350
- try {
351
- return await fn((req.body ?? {}));
352
- }
353
- catch (e) {
354
- if (e instanceof ValidationError) {
355
- const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
356
- return reply.code(422).send({ error: `Validation error — ${detail}`, issues: e.issues });
357
- }
358
- if (e instanceof HttpError)
359
- return reply.code(e.status).send({ error: e.message });
360
- return reply.code(400).send({ error: e.message });
361
- }
362
- });
326
+ registerPluginAdminApi(app, requireAdmin);
327
+ registerPluginUserApi(app);
363
328
  app.get('/api/fs/list', async (req, reply) => {
364
329
  const home = homedir();
365
330
  const q = req.query;
@@ -383,12 +348,12 @@ app.get('/api/fs/list', async (req, reply) => {
383
348
  return { dir: abs, entries: [] };
384
349
  }
385
350
  });
386
- function maskRecordRow(library, type, row) {
387
- const m = getShelf(library, type);
351
+ function maskRecordRow(library, shelf, row) {
352
+ const m = getShelf(library, shelf);
388
353
  let out = m ? maskTree(m.fields, row) : row;
389
354
  const ext = out._extends;
390
355
  if (ext) {
391
- const defs = getExtendsFor(library, type);
356
+ const defs = getExtendsFor(library, shelf);
392
357
  const maskedExt = {};
393
358
  for (const [id, sub] of Object.entries(ext)) {
394
359
  const def = defs.find((e) => e.id === id);
@@ -398,67 +363,77 @@ function maskRecordRow(library, type, row) {
398
363
  }
399
364
  return out;
400
365
  }
401
- app.get('/api/:library/:type', async (req, reply) => {
402
- const { library, type } = req.params;
403
- const { _fields, _extends, ...query } = req.query;
366
+ app.get('/api/:library/:shelf', async (req, reply) => {
367
+ const { library, shelf } = req.params;
368
+ const { _fields, _extends, limit, offset, ...query } = req.query;
369
+ const paged = isPagedRequest(limit, offset);
370
+ const opts = {
371
+ view: _fields === 'full' ? 'full' : 'list',
372
+ extends: _extends === '1',
373
+ };
404
374
  try {
405
- const rows = await recordList(library, type, query, {
406
- view: _fields === 'full' ? 'full' : 'list',
407
- extends: _extends === '1',
375
+ if (!paged) {
376
+ const rows = await recordList(library, shelf, query, opts);
377
+ return rows.map((r) => maskRecordRow(library, shelf, r));
378
+ }
379
+ const page = await recordListPage(library, shelf, query, {
380
+ ...opts,
381
+ limit: limit === undefined ? undefined : Number(limit),
382
+ offset: offset === undefined ? undefined : Number(offset),
408
383
  });
409
- return rows.map((r) => maskRecordRow(library, type, r));
384
+ return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
410
385
  }
411
386
  catch (e) {
412
- if (e instanceof UnknownTypeError)
413
- return reply.code(404).send({ error: 'unknown_type' });
387
+ if (e instanceof UnknownShelfError)
388
+ return reply.code(404).send({ error: 'unknown_shelf' });
414
389
  throw e;
415
390
  }
416
391
  });
417
- app.get('/api/:library/:type/:id', async (req, reply) => {
418
- const { library, type, id } = req.params;
392
+ app.get('/api/:library/:shelf/:id', async (req, reply) => {
393
+ const { library, shelf, id } = req.params;
419
394
  const rid = Number(id);
420
395
  if (isNaN(rid))
421
396
  return reply.code(400).send({ error: 'invalid_id' });
422
397
  try {
423
- const row = await recordGet(library, type, rid);
398
+ const row = await recordGet(library, shelf, rid);
424
399
  if (!row)
425
400
  return reply.code(404).send({ error: 'not_found' });
426
- return maskRecordRow(library, type, row);
401
+ return maskRecordRow(library, shelf, row);
427
402
  }
428
403
  catch (e) {
429
- if (e instanceof UnknownTypeError)
430
- return reply.code(404).send({ error: 'unknown_type' });
404
+ if (e instanceof UnknownShelfError)
405
+ return reply.code(404).send({ error: 'unknown_shelf' });
431
406
  throw e;
432
407
  }
433
408
  });
434
- app.post('/api/:library/:type', (req, reply) => guard(reply, async () => {
435
- const { library, type } = req.params;
409
+ app.post('/api/:library/:shelf', (req, reply) => guard(reply, async () => {
410
+ const { library, shelf } = req.params;
436
411
  try {
437
- const m = getShelf(library, type);
412
+ const m = getShelf(library, shelf);
438
413
  let body = (req.body ?? {});
439
414
  if (m)
440
415
  body = preserveTree(m.fields, body, {});
441
- const row = await recordCreate(library, type, body);
442
- return reply.code(201).send(maskRecordRow(library, type, row));
416
+ const row = await recordCreate(library, shelf, body);
417
+ return reply.code(201).send(maskRecordRow(library, shelf, row));
443
418
  }
444
419
  catch (e) {
445
- if (e instanceof UnknownTypeError)
446
- return reply.code(404).send({ error: 'unknown_type' });
420
+ if (e instanceof UnknownShelfError)
421
+ return reply.code(404).send({ error: 'unknown_shelf' });
447
422
  throw e;
448
423
  }
449
424
  }));
450
- app.patch('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
451
- const { library, type, id } = req.params;
425
+ app.patch('/api/:library/:shelf/:id', (req, reply) => guard(reply, async () => {
426
+ const { library, shelf, id } = req.params;
452
427
  const rid = Number(id);
453
428
  if (isNaN(rid))
454
429
  return reply.code(400).send({ error: 'invalid_id' });
455
430
  try {
456
- const m = getShelf(library, type);
431
+ const m = getShelf(library, shelf);
457
432
  let body = (req.body ?? {});
458
433
  if (m) {
459
- const existing = (await recordGet(library, type, rid)) ?? {};
434
+ const existing = (await recordGet(library, shelf, rid)) ?? {};
460
435
  body = preserveTree(m.fields, body, existing);
461
- const exDefs = getExtendsFor(library, type);
436
+ const exDefs = getExtendsFor(library, shelf);
462
437
  const exRecs = (existing._extends ?? {});
463
438
  for (const def of exDefs) {
464
439
  const bk = `_extend_${def.id}`;
@@ -468,27 +443,27 @@ app.patch('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
468
443
  }
469
444
  }
470
445
  }
471
- const row = await recordUpdate(library, type, rid, body);
472
- return maskRecordRow(library, type, row);
446
+ const row = await recordUpdate(library, shelf, rid, body);
447
+ return maskRecordRow(library, shelf, row);
473
448
  }
474
449
  catch (e) {
475
- if (e instanceof UnknownTypeError)
476
- return reply.code(404).send({ error: 'unknown_type' });
450
+ if (e instanceof UnknownShelfError)
451
+ return reply.code(404).send({ error: 'unknown_shelf' });
477
452
  throw e;
478
453
  }
479
454
  }));
480
- app.delete('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
481
- const { library, type, id } = req.params;
455
+ app.delete('/api/:library/:shelf/:id', (req, reply) => guard(reply, async () => {
456
+ const { library, shelf, id } = req.params;
482
457
  const rid = Number(id);
483
458
  if (isNaN(rid))
484
459
  return reply.code(400).send({ error: 'invalid_id' });
485
460
  try {
486
- await recordDelete(library, type, rid);
461
+ await recordDelete(library, shelf, rid);
487
462
  return reply.code(204).send();
488
463
  }
489
464
  catch (e) {
490
- if (e instanceof UnknownTypeError)
491
- return reply.code(404).send({ error: 'unknown_type' });
465
+ if (e instanceof UnknownShelfError)
466
+ return reply.code(404).send({ error: 'unknown_shelf' });
492
467
  throw e;
493
468
  }
494
469
  }));
@@ -1,5 +1,5 @@
1
- import { UnknownTypeError } from './records-api.ts';
2
- export { UnknownTypeError };
3
- export declare function localExists(library: string, type: string, id: number): Promise<boolean>;
4
- export declare function localPost(library: string, type: string, body: unknown): Promise<Record<string, unknown>>;
5
- export declare function localPatch(library: string, type: string, id: number, body: unknown): Promise<Record<string, unknown>>;
1
+ import { UnknownShelfError } from './records-api.ts';
2
+ export { UnknownShelfError };
3
+ export declare function localExists(library: string, shelf: string, id: number): Promise<boolean>;
4
+ export declare function localPost(library: string, shelf: string, body: unknown): Promise<Record<string, unknown>>;
5
+ export declare function localPatch(library: string, shelf: string, id: number, body: unknown): Promise<Record<string, unknown>>;
package/dist/local-api.js CHANGED
@@ -1,18 +1,18 @@
1
- import { recordCreate, recordUpdate, recordGet, UnknownTypeError } from "./records-api.js";
2
- export { UnknownTypeError };
3
- export async function localExists(library, type, id) {
1
+ import { recordCreate, recordUpdate, recordGet, UnknownShelfError } from "./records-api.js";
2
+ export { UnknownShelfError };
3
+ export async function localExists(library, shelf, id) {
4
4
  try {
5
- return Boolean(await recordGet(library, type, id));
5
+ return Boolean(await recordGet(library, shelf, id));
6
6
  }
7
7
  catch (e) {
8
- if (e instanceof UnknownTypeError)
8
+ if (e instanceof UnknownShelfError)
9
9
  return false;
10
10
  throw e;
11
11
  }
12
12
  }
13
- export function localPost(library, type, body) {
14
- return recordCreate(library, type, body, 'import');
13
+ export function localPost(library, shelf, body) {
14
+ return recordCreate(library, shelf, body, 'import');
15
15
  }
16
- export function localPatch(library, type, id, body) {
17
- return recordUpdate(library, type, id, body, 'import');
16
+ export function localPatch(library, shelf, id, body) {
17
+ return recordUpdate(library, shelf, id, body, 'import');
18
18
  }
package/dist/mcp-http.js CHANGED
@@ -20,7 +20,19 @@ export async function registerMcpHttp(app) {
20
20
  const actor = req.user.login;
21
21
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
22
22
  reply.hijack();
23
- reply.raw.on('close', () => void transport.close());
23
+ let closed = false;
24
+ const closeTransport = async () => {
25
+ if (closed)
26
+ return;
27
+ closed = true;
28
+ try {
29
+ await transport.close();
30
+ }
31
+ catch (e) {
32
+ log.debug(`/mcp transport close: ${e.message}`);
33
+ }
34
+ };
35
+ reply.raw.on('close', () => void closeTransport());
24
36
  try {
25
37
  const server = await buildMcpServer(role, actor);
26
38
  await server.connect(transport);
@@ -33,5 +45,8 @@ export async function registerMcpHttp(app) {
33
45
  reply.raw.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'internal' }, id: null }));
34
46
  }
35
47
  }
48
+ finally {
49
+ await closeTransport();
50
+ }
36
51
  });
37
52
  }
@@ -1,10 +1,17 @@
1
1
  import type { CofferClientApi } from '@coffer-org/mcp/client';
2
2
  export declare function mapError(e: unknown): never;
3
+ export declare function splitListQuery(raw: Record<string, unknown>): {
4
+ query: Record<string, unknown>;
5
+ opts: {
6
+ limit?: number;
7
+ offset?: number;
8
+ };
9
+ };
3
10
  export declare class LocalClient implements CofferClientApi {
4
11
  getSchema(): Promise<unknown>;
5
- listRecords(library: string, type: string, query?: Record<string, unknown>): Promise<unknown>;
6
- getRecord(library: string, type: string, id: number): Promise<unknown>;
7
- createRecord(library: string, type: string, fields: Record<string, unknown>): Promise<unknown>;
8
- updateRecord(library: string, type: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
9
- deleteRecord(library: string, type: string, id: number): Promise<unknown>;
12
+ listRecords(library: string, shelf: string, raw?: Record<string, unknown>): Promise<unknown>;
13
+ getRecord(library: string, shelf: string, id: number): Promise<unknown>;
14
+ createRecord(library: string, shelf: string, fields: Record<string, unknown>): Promise<unknown>;
15
+ updateRecord(library: string, shelf: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
16
+ deleteRecord(library: string, shelf: string, id: number): Promise<unknown>;
10
17
  }
package/dist/mcp-local.js CHANGED
@@ -1,53 +1,62 @@
1
1
  import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
2
- import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, UnknownTypeError, } from "./records-api.js";
2
+ import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, } from "./records-api.js";
3
3
  import { ValidationError as ServerValidationError } from "./mutate.js";
4
4
  import { buildClientSchema } from "./schema-api.js";
5
5
  export function mapError(e) {
6
6
  if (e instanceof ServerValidationError)
7
7
  throw new ValidationError(e.issues ?? []);
8
- if (e instanceof UnknownTypeError)
8
+ if (e instanceof UnknownShelfError)
9
9
  throw new NotFoundError('not_found');
10
10
  throw e;
11
11
  }
12
+ export function splitListQuery(raw) {
13
+ const { limit, offset, ...query } = raw;
14
+ if (typeof limit !== 'number')
15
+ return { query, opts: {} };
16
+ return { query, opts: { limit, ...(typeof offset === 'number' ? { offset } : {}) } };
17
+ }
12
18
  export class LocalClient {
13
19
  async getSchema() {
14
20
  return buildClientSchema();
15
21
  }
16
- async listRecords(library, type, query = {}) {
22
+ async listRecords(library, shelf, raw = {}) {
23
+ const { query, opts } = splitListQuery(raw);
17
24
  try {
18
- return await recordList(library, type, query);
25
+ if (opts.limit === undefined)
26
+ return await recordList(library, shelf, query);
27
+ return await recordListPage(library, shelf, query, opts);
19
28
  }
20
29
  catch (e) {
21
30
  mapError(e);
22
31
  }
23
32
  }
24
- async getRecord(library, type, id) {
33
+ async getRecord(library, shelf, id) {
25
34
  try {
26
- return await recordGet(library, type, id);
35
+ return await recordGet(library, shelf, id);
27
36
  }
28
37
  catch (e) {
29
38
  mapError(e);
30
39
  }
31
40
  }
32
- async createRecord(library, type, fields) {
41
+ async createRecord(library, shelf, fields) {
33
42
  try {
34
- return await recordCreate(library, type, fields);
43
+ return await recordCreate(library, shelf, fields);
35
44
  }
36
45
  catch (e) {
37
46
  mapError(e);
38
47
  }
39
48
  }
40
- async updateRecord(library, type, id, fields) {
49
+ async updateRecord(library, shelf, id, fields) {
41
50
  try {
42
- return await recordUpdate(library, type, id, fields);
51
+ return await recordUpdate(library, shelf, id, fields);
43
52
  }
44
53
  catch (e) {
45
54
  mapError(e);
46
55
  }
47
56
  }
48
- async deleteRecord(library, type, id) {
57
+ async deleteRecord(library, shelf, id) {
49
58
  try {
50
- await recordDelete(library, type, id);
59
+ await recordDelete(library, shelf, id);
51
60
  return null;
52
61
  }
53
62
  catch (e) {
package/dist/mcp-tools.js CHANGED
@@ -2,9 +2,11 @@ import { z } from 'zod';
2
2
  import { buildTools } from '@coffer-org/mcp';
3
3
  import { SchemaCache } from '@coffer-org/mcp/schema';
4
4
  import { LocalClient } from "./mcp-local.js";
5
+ import { frontendInstructions } from "./frontend-agent.js";
5
6
  import { mintUploadTicket } from "./upload-ticket.js";
6
7
  import { pluginHooks, pluginCtx } from "./plugin-hooks.js";
7
8
  import { getActiveRegistry } from "./registry-context.js";
9
+ import { countTargetsFor, recordCounts } from "./counts.js";
8
10
  import { describeCondition } from '@coffer-org/sdk/condition';
9
11
  import { getEm } from "./db.js";
10
12
  import { getPluginSettings } from "./plugin-runtime.js";
@@ -18,7 +20,7 @@ export function formatHits(hits) {
18
20
  if (hits.length === 0)
19
21
  return 'No matching records.';
20
22
  return hits
21
- .map((h) => `[${h.type}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
23
+ .map((h) => `[${h.shelfKey}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
22
24
  .join('\n\n');
23
25
  }
24
26
  const ok = (data) => ({
@@ -56,7 +58,7 @@ export async function collectMcpTools(opts = {}) {
56
58
  server: 'coffer',
57
59
  bareName: 'create_upload_ticket',
58
60
  httpName: 'create_upload_ticket',
59
- description: 'Mint a short-lived (60 min) upload-only token for POST /api/upload. Use it as a Bearer header to stream local files to the server without sending their bytes through this conversation. The token cannot read or modify records. Response of each upload is {"filename":"<name>"} — store it in a file/image field as {"name":"<name>"}.',
61
+ description: 'Mint a short-lived (60 min) upload-only token for POST /api/upload. Use it as a Bearer header to stream local files to the server without sending their bytes through this conversation. The token cannot read or modify records. Each upload responds {"name":"<name>","mime":...,"size":...} — store it in a file/image/media field as {"name":"<name>"}. This is the ONLY way to fill such a field: a remote URL written straight into the field is rejected, so download the file locally first and upload that.',
60
62
  inputSchema: {},
61
63
  scope: 'upload',
62
64
  role: 'member',
@@ -66,11 +68,42 @@ export async function collectMcpTools(opts = {}) {
66
68
  token,
67
69
  upload_url: '/api/upload',
68
70
  expires_in: expiresInSec,
69
- how_to: 'curl -H "Authorization: Bearer <token>" -F "file=@<path>" <base-url>/api/upload → {"filename":"<name>"}. Then set a file field to {"name":"<name>"}.',
71
+ how_to: 'curl -H "Authorization: Bearer <token>" -F "file=@<path>" <base-url>/api/upload → {"name":"<name>"}. Then set a file field to {"name":"<name>"}. mime/size are filled in by the server — do not send your own.',
70
72
  });
71
73
  },
72
74
  });
73
75
  }
76
+ out.push({
77
+ server: 'coffer',
78
+ bareName: 'count_records',
79
+ httpName: 'count_records',
80
+ description: 'How many records each shelf holds — counted in the database, no rows returned. ' +
81
+ 'Without arguments: every shelf of every library. library — narrow to one library; shelf — to one shelf ' +
82
+ '(needs library). Use this for "how many …" and for a size overview; never list records just to count them.',
83
+ inputSchema: { library: z.string().optional(), shelf: z.string().optional() },
84
+ scope: 'crud',
85
+ role: 'member',
86
+ handler: async (args) => {
87
+ try {
88
+ const registry = getActiveRegistry();
89
+ const targets = countTargetsFor(registry.shelves, {
90
+ ...(typeof args.library === 'string' ? { library: args.library } : {}),
91
+ ...(typeof args.shelf === 'string' ? { shelf: args.shelf } : {}),
92
+ });
93
+ const counts = await recordCounts(targets);
94
+ const shelves = Object.entries(counts)
95
+ .map(([key, count]) => {
96
+ const [library = '', shelf = ''] = key.split('/');
97
+ return { library, shelf, count };
98
+ })
99
+ .sort((a, b) => b.count - a.count);
100
+ return ok({ shelves, total: shelves.reduce((n, s) => n + s.count, 0) });
101
+ }
102
+ catch (e) {
103
+ return fail(`Error: ${e.message}`);
104
+ }
105
+ },
106
+ });
74
107
  for (const [id, h] of Object.entries(pluginHooks)) {
75
108
  for (const t of h.agent?.tools ?? []) {
76
109
  out.push({
@@ -99,7 +132,7 @@ export async function collectMcpTools(opts = {}) {
99
132
  server: 'rag',
100
133
  bareName: 'search_records',
101
134
  httpName: 'search_records',
102
- description: "Semantic search over the user's coffer records. Returns the most relevant records as type/id refs with a text snippet.",
135
+ description: "Semantic search over the user's coffer records. Returns the most relevant records as library/shelf/id refs with a text snippet.",
103
136
  inputSchema: { query: z.string(), k: z.number().int().positive().optional() },
104
137
  scope: 'rag',
105
138
  role: 'member',
@@ -227,21 +260,31 @@ export async function buildDomainSections() {
227
260
  '## Libraries (what each holds / when to use it — pick the right one before searching)\n\n' + blocks.join('\n\n');
228
261
  }
229
262
  const dataModel = '## Data model\n' +
230
- 'Library (top-level area) → type/shelf (a kind of record, e.g. things/item) → record (addressed library/type/id) → fields. ' +
263
+ 'Library (top-level area) → shelf (a kind of record, e.g. things/item) → record (addressed library/shelf/id) → fields. ' +
231
264
  'Some field values are JSON (e.g. quantity {"value":2000,"unit":"ml"}); some are relations (hold another record\'s id); ' +
232
- 'some are collections (nested rows — an array). Extends add extra field-sets to a type\'s records, shown only when a ' +
265
+ "some are collections (nested rows — an array). Extends add extra field-sets to a shelf's records, shown only when a " +
233
266
  'condition holds (the "when …" notes below); in a fetched record they sit under `_extends`. ' +
234
- 'Read: list_libraries → describe_type → list_records/get_record. Write: create_record/update_record (call describe_type first); ' +
267
+ 'Read: list_libraries → describe_shelf → list_records/get_record. Write: create_record/update_record (call describe_shelf first); ' +
235
268
  'to remove a record use delete_record — do not blank its fields.';
236
269
  const rules = (await collectPluginInstructions()).map(({ id, instructions }) => `## ${id}\n${instructions}`);
237
- return overview ? [dataModel, overview, ...rules] : [dataModel, ...rules];
270
+ const site = await frontendInstructions();
271
+ return [
272
+ dataModel,
273
+ ...(overview ? [overview] : []),
274
+ ...(site ? [`## web\n${site}`] : []),
275
+ ...rules,
276
+ ];
238
277
  }
239
278
  export function buildMcpInstructions(sections) {
240
279
  const base = [
241
280
  "Coffer is the user's personal database, organized into libraries (kitchen, people, finance, health, devices, home, garden, documents, travel, and more), each holding typed records.",
242
- 'Use the tools to read and write this data: call list_libraries to see what exists, describe_type before create_record/update_record, then list_records / get_record to read. When a search_records tool is available, use it for semantic lookup.',
243
- 'The per-library notes below name the shelves and the rules — not every field. For exact field names, types, and which are required, call describe_type(library, type) rather than guessing from the notes.',
281
+ 'Use the tools to read and write this data: call list_libraries to see what exists, describe_shelf before create_record/update_record, then list_records / get_record to read. When a search_records tool is available, use it for semantic lookup.',
282
+ 'The per-library notes below name the shelves and the rules — not every field. For exact field names, types, and which are required, call describe_shelf(library, shelf) rather than guessing from the notes.',
244
283
  'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse them.',
284
+ 'File fields (file/document/audio/video/image/media/avatar/cover/poster) hold a file uploaded to this server, ' +
285
+ 'never a remote URL: {"name":"<filename from POST /api/upload>"}. Writing a URL is rejected. ' +
286
+ 'To store a picture found on the web, download it locally first, then create_upload_ticket → POST /api/upload → write the returned name. ' +
287
+ 'See the field\'s "write" note in describe_shelf.',
245
288
  ].join('\n');
246
289
  return sections.length ? `${base}\n\n${sections.join('\n\n')}` : base;
247
290
  }
@@ -10,6 +10,7 @@ export declare function introspectTable(em: EntityManager, table: string): Promi
10
10
  }[];
11
11
  }>;
12
12
  export declare function makeTable(em: EntityManager, table: string): TableOps;
13
+ export declare function renameSystemShelfKey(em: EntityManager): Promise<void>;
13
14
  export declare function validateMigrations(pluginId: string, list: Migration[]): void;
14
15
  interface RunArgs {
15
16
  em: EntityManager;