@coffer-org/server 1.14.0 → 2.0.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/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
  }
@@ -2,9 +2,9 @@ import type { CofferClientApi } from '@coffer-org/mcp/client';
2
2
  export declare function mapError(e: unknown): never;
3
3
  export declare class LocalClient implements CofferClientApi {
4
4
  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>;
5
+ listRecords(library: string, shelf: string, query?: Record<string, unknown>): Promise<unknown>;
6
+ getRecord(library: string, shelf: string, id: number): Promise<unknown>;
7
+ createRecord(library: string, shelf: string, fields: Record<string, unknown>): Promise<unknown>;
8
+ updateRecord(library: string, shelf: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
9
+ deleteRecord(library: string, shelf: string, id: number): Promise<unknown>;
10
10
  }
package/dist/mcp-local.js CHANGED
@@ -1,11 +1,11 @@
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, 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
  }
@@ -13,41 +13,41 @@ export class LocalClient {
13
13
  async getSchema() {
14
14
  return buildClientSchema();
15
15
  }
16
- async listRecords(library, type, query = {}) {
16
+ async listRecords(library, shelf, query = {}) {
17
17
  try {
18
- return await recordList(library, type, query);
18
+ return await recordList(library, shelf, query);
19
19
  }
20
20
  catch (e) {
21
21
  mapError(e);
22
22
  }
23
23
  }
24
- async getRecord(library, type, id) {
24
+ async getRecord(library, shelf, id) {
25
25
  try {
26
- return await recordGet(library, type, id);
26
+ return await recordGet(library, shelf, id);
27
27
  }
28
28
  catch (e) {
29
29
  mapError(e);
30
30
  }
31
31
  }
32
- async createRecord(library, type, fields) {
32
+ async createRecord(library, shelf, fields) {
33
33
  try {
34
- return await recordCreate(library, type, fields);
34
+ return await recordCreate(library, shelf, fields);
35
35
  }
36
36
  catch (e) {
37
37
  mapError(e);
38
38
  }
39
39
  }
40
- async updateRecord(library, type, id, fields) {
40
+ async updateRecord(library, shelf, id, fields) {
41
41
  try {
42
- return await recordUpdate(library, type, id, fields);
42
+ return await recordUpdate(library, shelf, id, fields);
43
43
  }
44
44
  catch (e) {
45
45
  mapError(e);
46
46
  }
47
47
  }
48
- async deleteRecord(library, type, id) {
48
+ async deleteRecord(library, shelf, id) {
49
49
  try {
50
- await recordDelete(library, type, id);
50
+ await recordDelete(library, shelf, id);
51
51
  return null;
52
52
  }
53
53
  catch (e) {
package/dist/mcp-tools.js CHANGED
@@ -2,6 +2,7 @@ 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";
@@ -18,7 +19,7 @@ export function formatHits(hits) {
18
19
  if (hits.length === 0)
19
20
  return 'No matching records.';
20
21
  return hits
21
- .map((h) => `[${h.type}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
22
+ .map((h) => `[${h.shelfKey}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
22
23
  .join('\n\n');
23
24
  }
24
25
  const ok = (data) => ({
@@ -56,7 +57,7 @@ export async function collectMcpTools(opts = {}) {
56
57
  server: 'coffer',
57
58
  bareName: 'create_upload_ticket',
58
59
  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>"}.',
60
+ 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
61
  inputSchema: {},
61
62
  scope: 'upload',
62
63
  role: 'member',
@@ -66,7 +67,7 @@ export async function collectMcpTools(opts = {}) {
66
67
  token,
67
68
  upload_url: '/api/upload',
68
69
  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>"}.',
70
+ 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
71
  });
71
72
  },
72
73
  });
@@ -99,7 +100,7 @@ export async function collectMcpTools(opts = {}) {
99
100
  server: 'rag',
100
101
  bareName: 'search_records',
101
102
  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.",
103
+ description: "Semantic search over the user's coffer records. Returns the most relevant records as library/shelf/id refs with a text snippet.",
103
104
  inputSchema: { query: z.string(), k: z.number().int().positive().optional() },
104
105
  scope: 'rag',
105
106
  role: 'member',
@@ -227,21 +228,31 @@ export async function buildDomainSections() {
227
228
  '## Libraries (what each holds / when to use it — pick the right one before searching)\n\n' + blocks.join('\n\n');
228
229
  }
229
230
  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. ' +
231
+ 'Library (top-level area) → shelf (a kind of record, e.g. things/item) → record (addressed library/shelf/id) → fields. ' +
231
232
  '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 ' +
233
+ "some are collections (nested rows — an array). Extends add extra field-sets to a shelf's records, shown only when a " +
233
234
  '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); ' +
235
+ 'Read: list_libraries → describe_shelf → list_records/get_record. Write: create_record/update_record (call describe_shelf first); ' +
235
236
  'to remove a record use delete_record — do not blank its fields.';
236
237
  const rules = (await collectPluginInstructions()).map(({ id, instructions }) => `## ${id}\n${instructions}`);
237
- return overview ? [dataModel, overview, ...rules] : [dataModel, ...rules];
238
+ const site = await frontendInstructions();
239
+ return [
240
+ dataModel,
241
+ ...(overview ? [overview] : []),
242
+ ...(site ? [`## web\n${site}`] : []),
243
+ ...rules,
244
+ ];
238
245
  }
239
246
  export function buildMcpInstructions(sections) {
240
247
  const base = [
241
248
  "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.',
249
+ '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.',
250
+ '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
251
  'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse them.',
252
+ 'File fields (file/document/audio/video/image/media/avatar/cover/poster) hold a file uploaded to this server, ' +
253
+ 'never a remote URL: {"name":"<filename from POST /api/upload>"}. Writing a URL is rejected. ' +
254
+ 'To store a picture found on the web, download it locally first, then create_upload_ticket → POST /api/upload → write the returned name. ' +
255
+ 'See the field\'s "write" note in describe_shelf.',
245
256
  ].join('\n');
246
257
  return sections.length ? `${base}\n\n${sections.join('\n\n')}` : base;
247
258
  }
@@ -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;
@@ -179,8 +179,31 @@ export function makeTable(em, table) {
179
179
  throw e;
180
180
  }
181
181
  },
182
+ async dropColumn(column) {
183
+ const info = await introspectTable(em, table);
184
+ if (!info.exists || !info.columns.has(column))
185
+ return;
186
+ if (dialectOf(em) === 'sqlite') {
187
+ const idxList = (await conn.execute(`PRAGMA index_list(${q(table)})`));
188
+ for (const idx of idxList) {
189
+ const info = (await conn.execute(`PRAGMA index_info(${q(idx.name)})`));
190
+ if (!info.some((c) => c.name === column))
191
+ continue;
192
+ const meta = (await conn.execute(`SELECT sql FROM sqlite_master WHERE type='index' AND name=?`, [
193
+ idx.name,
194
+ ]));
195
+ const sql = meta[0]?.sql;
196
+ if (sql !== null)
197
+ await conn.execute(`DROP INDEX ${q(idx.name)}`);
198
+ }
199
+ }
200
+ await conn.execute(`ALTER TABLE ${T} DROP COLUMN ${q(column)}`);
201
+ },
182
202
  };
183
203
  }
204
+ export async function renameSystemShelfKey(em) {
205
+ await makeTable(em, '_embeddings').renameColumn('type', 'shelf_key');
206
+ }
184
207
  export function validateMigrations(pluginId, list) {
185
208
  const versions = list.map((m) => m.version).sort((a, b) => a - b);
186
209
  versions.forEach((v, i) => {
package/dist/mutate.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
2
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
3
3
  import { z } from 'zod';
4
+ export declare function encodeJson(m: ShelfDef, data: Record<string, unknown>): Record<string, unknown>;
4
5
  export interface MutateCtx {
5
6
  actor: string;
6
7
  }