@coffer-org/server 3.4.0 → 4.0.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/auth-api.js +4 -5
- package/dist/cache-clock.d.ts +8 -0
- package/dist/cache-clock.js +103 -0
- package/dist/cache-refresh.d.ts +7 -0
- package/dist/cache-refresh.js +322 -0
- package/dist/collection-io.d.ts +13 -0
- package/dist/collection-io.js +40 -29
- package/dist/compute-unit.d.ts +66 -0
- package/dist/compute-unit.js +369 -0
- package/dist/condition-where.js +1 -3
- package/dist/data-dir.d.ts +3 -0
- package/dist/data-dir.js +39 -0
- package/dist/entity-schema.js +5 -2
- package/dist/extend-table.d.ts +1 -1
- package/dist/extend-table.js +22 -8
- package/dist/file-fields.js +50 -23
- package/dist/global-search.js +1 -1
- package/dist/index.js +20 -26
- package/dist/mcp-contract/client.d.ts +24 -0
- package/dist/mcp-contract/client.js +12 -0
- package/dist/mcp-contract/schema.d.ts +46 -0
- package/dist/mcp-contract/schema.js +90 -0
- package/dist/mcp-contract/tools.d.ts +19 -0
- package/dist/mcp-contract/tools.js +160 -0
- package/dist/mcp-local.d.ts +1 -1
- package/dist/mcp-local.js +1 -1
- package/dist/mcp-tools.d.ts +12 -1
- package/dist/mcp-tools.js +41 -8
- package/dist/migrations.d.ts +5 -0
- package/dist/migrations.js +122 -0
- package/dist/mutate.js +77 -36
- package/dist/orchestrator/agent-capabilities.d.ts +2 -0
- package/dist/orchestrator/agent-capabilities.js +201 -0
- package/dist/orchestrator/allow.d.ts +16 -0
- package/dist/orchestrator/allow.js +65 -0
- package/dist/orchestrator/attachments.d.ts +2 -0
- package/dist/orchestrator/attachments.js +11 -0
- package/dist/orchestrator/config.d.ts +4 -0
- package/dist/orchestrator/config.js +19 -0
- package/dist/orchestrator/db.d.ts +17 -0
- package/dist/orchestrator/db.js +12 -0
- package/dist/orchestrator/diagnostics.d.ts +9 -0
- package/dist/orchestrator/diagnostics.js +13 -0
- package/dist/orchestrator/environment.d.ts +3 -0
- package/dist/orchestrator/environment.js +30 -0
- package/dist/orchestrator/file-inspection.d.ts +3 -0
- package/dist/orchestrator/file-inspection.js +61 -0
- package/dist/orchestrator/format.d.ts +1 -0
- package/dist/orchestrator/format.js +6 -0
- package/dist/orchestrator/index.d.ts +30 -0
- package/dist/orchestrator/index.js +56 -0
- package/dist/orchestrator/live-message.d.ts +14 -0
- package/dist/orchestrator/live-message.js +100 -0
- package/dist/orchestrator/pipeline.d.ts +11 -0
- package/dist/orchestrator/pipeline.js +198 -0
- package/dist/orchestrator/registry.d.ts +13 -0
- package/dist/orchestrator/registry.js +74 -0
- package/dist/orchestrator/starters.d.ts +19 -0
- package/dist/orchestrator/starters.js +81 -0
- package/dist/orchestrator/suggestion-capabilities.d.ts +5 -0
- package/dist/orchestrator/suggestion-capabilities.js +33 -0
- package/dist/orchestrator/system-assembly.d.ts +7 -0
- package/dist/orchestrator/system-assembly.js +13 -0
- package/dist/orchestrator/system-capabilities.d.ts +2 -0
- package/dist/orchestrator/system-capabilities.js +63 -0
- package/dist/orchestrator/types.d.ts +144 -0
- package/dist/orchestrator/types.js +1 -0
- package/dist/part-injection.d.ts +3 -4
- package/dist/part-injection.js +25 -368
- package/dist/plugin-hooks.d.ts +1 -0
- package/dist/plugin-i18n.js +2 -1
- package/dist/plugin-runtime.d.ts +2 -2
- package/dist/plugin-runtime.js +20 -11
- package/dist/plugin-updates.d.ts +2 -2
- package/dist/plugin-updates.js +6 -12
- package/dist/public-url.js +2 -1
- package/dist/recompute-derived.d.ts +2 -1
- package/dist/recompute-derived.js +111 -34
- package/dist/records-api.js +14 -16
- package/dist/registry-context.d.ts +1 -1
- package/dist/registry-context.js +3 -0
- package/dist/search-index.js +1 -1
- package/dist/search-indexer.d.ts +1 -1
- package/dist/search-indexer.js +3 -3
- package/dist/settings-groups.d.ts +14 -0
- package/dist/settings-groups.js +25 -0
- package/dist/settings-write.d.ts +4 -4
- package/dist/settings-write.js +23 -26
- package/dist/system-settings.d.ts +3 -0
- package/dist/system-settings.js +27 -0
- package/dist/thread-store.d.ts +3 -2
- package/dist/thread-store.js +6 -5
- package/dist/trigger-queue.d.ts +33 -0
- package/dist/trigger-queue.js +432 -0
- package/dist/uploads.js +6 -3
- package/dist/value-compare.d.ts +1 -0
- package/dist/value-compare.js +7 -0
- package/package.json +17 -3
package/dist/mcp-tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import { buildTools } from
|
|
3
|
-
import { SchemaCache } from
|
|
3
|
+
import { buildTools } from "./mcp-contract/tools.js";
|
|
4
|
+
import { SchemaCache } from "./mcp-contract/schema.js";
|
|
4
5
|
import { LocalClient } from "./mcp-local.js";
|
|
5
6
|
import { frontendInstructions } from "./frontend-agent.js";
|
|
6
7
|
import { mintUploadTicket } from "./upload-ticket.js";
|
|
@@ -81,7 +82,7 @@ export async function collectMcpTools(opts = {}) {
|
|
|
81
82
|
how_to: `curl -H "Authorization: Bearer <token>" -F "file=@<path>" ${uploadUrl} → {"name":"<name>"}. Then set a file field to {"name":"<name>"}. mime/size are filled in by the server — do not send your own.` +
|
|
82
83
|
(base
|
|
83
84
|
? ''
|
|
84
|
-
: " The server has no public URL configured (
|
|
85
|
+
: " The server has no public URL configured (system settings publicUrl / PUBLIC_URL env), so the path is relative — resolve it against this MCP server's own origin."),
|
|
85
86
|
});
|
|
86
87
|
},
|
|
87
88
|
});
|
|
@@ -204,7 +205,7 @@ export async function collectMcpTools(opts = {}) {
|
|
|
204
205
|
server: 'coffer',
|
|
205
206
|
bareName: 'list_settings',
|
|
206
207
|
httpName: 'list_settings',
|
|
207
|
-
description:
|
|
208
|
+
description: "List every settings group (the instance's own `system` group and each plugin's): each field's key/kind/required and the current values (secrets masked). Call before update_settings.",
|
|
208
209
|
inputSchema: {},
|
|
209
210
|
scope: 'settings',
|
|
210
211
|
role: 'admin',
|
|
@@ -221,13 +222,13 @@ export async function collectMcpTools(opts = {}) {
|
|
|
221
222
|
server: 'coffer',
|
|
222
223
|
bareName: 'update_settings',
|
|
223
224
|
httpName: 'update_settings',
|
|
224
|
-
description: 'Update a plugin or system settings group.
|
|
225
|
-
inputSchema: {
|
|
225
|
+
description: 'Update a plugin or system settings group. group — the settings group id (e.g. "system", "claude-agent"); fields — only the settings to change (see list_settings). Send real secret values; the masked placeholder (********) is treated as unchanged.',
|
|
226
|
+
inputSchema: { group: z.string(), fields: z.record(z.string(), z.unknown()) },
|
|
226
227
|
scope: 'settings',
|
|
227
228
|
role: 'admin',
|
|
228
229
|
handler: async (args) => {
|
|
229
230
|
try {
|
|
230
|
-
const row = await writePluginSettings(getEm().fork(), args.
|
|
231
|
+
const row = await writePluginSettings(getEm().fork(), args.group, args.fields, actor);
|
|
231
232
|
return ok(row);
|
|
232
233
|
}
|
|
233
234
|
catch (e) {
|
|
@@ -239,7 +240,7 @@ export async function collectMcpTools(opts = {}) {
|
|
|
239
240
|
return fail(`Validation error:\n${lines.join('\n')}`);
|
|
240
241
|
}
|
|
241
242
|
if (e instanceof NotFoundError)
|
|
242
|
-
return fail(`
|
|
243
|
+
return fail(`No settings group '${String(args.group)}'.`);
|
|
243
244
|
return fail(`Error: ${e.message}`);
|
|
244
245
|
}
|
|
245
246
|
},
|
|
@@ -349,6 +350,38 @@ export async function buildDomainSections() {
|
|
|
349
350
|
...rules,
|
|
350
351
|
];
|
|
351
352
|
}
|
|
353
|
+
export async function collectStarterHints(hooks = pluginHooks, emFactory = () => getEm().fork(), reg) {
|
|
354
|
+
const parts = [];
|
|
355
|
+
for (const [id, h] of Object.entries(hooks)) {
|
|
356
|
+
const hint = h.agent?.starterHint;
|
|
357
|
+
if (hint == null)
|
|
358
|
+
continue;
|
|
359
|
+
try {
|
|
360
|
+
const text = typeof hint === 'function' ? await hint(pluginCtx(id, emFactory())) : hint;
|
|
361
|
+
if (text)
|
|
362
|
+
parts.push(`- ${id}: ${text}`);
|
|
363
|
+
}
|
|
364
|
+
catch (e) {
|
|
365
|
+
log.warn(`plugin ${id}: starterHint skipped — ${e.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
let registry = reg;
|
|
369
|
+
if (!registry) {
|
|
370
|
+
try {
|
|
371
|
+
registry = getActiveRegistry();
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
registry = undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const v of registry?.libraries ?? []) {
|
|
378
|
+
if (v.meta.starterHint)
|
|
379
|
+
parts.push(`- ${v.meta.id}: ${v.meta.starterHint}`);
|
|
380
|
+
}
|
|
381
|
+
const combined = parts.join('\n');
|
|
382
|
+
const hash = createHash('sha1').update(combined).digest('hex');
|
|
383
|
+
return { combined, hash };
|
|
384
|
+
}
|
|
352
385
|
export function buildMcpInstructions(sections) {
|
|
353
386
|
const base = [
|
|
354
387
|
"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.",
|
package/dist/migrations.d.ts
CHANGED
|
@@ -29,6 +29,11 @@ export type OpRecorder = (op: MigrationOp) => void;
|
|
|
29
29
|
export declare function describeMigrationConcerns(plugin: string, version: number, ops: MigrationOp[]): string | null;
|
|
30
30
|
export declare function makeTable(em: EntityManager, table: string, record?: OpRecorder): TableOps;
|
|
31
31
|
export declare function renameSystemShelfKey(em: EntityManager): Promise<void>;
|
|
32
|
+
export declare const SYSTEM_MIGRATIONS: {
|
|
33
|
+
name: string;
|
|
34
|
+
up(em: EntityManager): Promise<void>;
|
|
35
|
+
}[];
|
|
36
|
+
export declare function runSystemMigrations(em: EntityManager): Promise<void>;
|
|
32
37
|
export declare function validateMigrations(pluginId: string, list: Migration[]): void;
|
|
33
38
|
interface RunArgs {
|
|
34
39
|
em: EntityManager;
|
package/dist/migrations.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DatabaseSchema } from '@mikro-orm/sql';
|
|
2
|
+
import { fieldEntries } from '@coffer-org/sdk/shelf';
|
|
2
3
|
import { dialectOf } from "./dialect.js";
|
|
3
4
|
import { getLogger } from '@coffer-org/sdk/logger';
|
|
5
|
+
import { SYSTEM_SETTINGS } from "./system-settings.js";
|
|
4
6
|
const log = getLogger('migrations');
|
|
5
7
|
function sqlType(col) {
|
|
6
8
|
if (col === 'integer' || col === 'boolean')
|
|
@@ -392,6 +394,126 @@ export function makeTable(em, table, record) {
|
|
|
392
394
|
export async function renameSystemShelfKey(em) {
|
|
393
395
|
await makeTable(em, '_embeddings').renameColumn('type', 'shelf_key');
|
|
394
396
|
}
|
|
397
|
+
const LEGACY_ORCHESTRATOR_FIELDS = ['agent_id', 'access_password', 'trigger_prefix', 'reply_window'];
|
|
398
|
+
export const SYSTEM_MIGRATIONS = [
|
|
399
|
+
{
|
|
400
|
+
name: 'embeddings-shelf-key',
|
|
401
|
+
up: renameSystemShelfKey,
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: 'core-settings-to-system',
|
|
405
|
+
up: async (em) => {
|
|
406
|
+
const conn = em.getConnection();
|
|
407
|
+
const src = await introspectTable(em, '_settings__core');
|
|
408
|
+
const dst = await introspectTable(em, '_settings__system');
|
|
409
|
+
if (!src.exists || !dst.exists)
|
|
410
|
+
return;
|
|
411
|
+
const already = (await conn.execute(`SELECT 1 FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
|
|
412
|
+
if (already.length > 0)
|
|
413
|
+
return;
|
|
414
|
+
const rows = (await conn.execute(`SELECT * FROM "_settings__core" WHERE "plugin_id" = 'core' LIMIT 1`));
|
|
415
|
+
const row = rows[0];
|
|
416
|
+
if (!row)
|
|
417
|
+
return;
|
|
418
|
+
const carried = ['publicUrl', 'language'].filter((c) => src.columns.has(c) && dst.columns.has(c));
|
|
419
|
+
if (carried.length === 0)
|
|
420
|
+
return;
|
|
421
|
+
const cols = ['plugin_id', ...carried];
|
|
422
|
+
const values = ['system', ...carried.map((c) => row[c] ?? null)];
|
|
423
|
+
await conn.execute(`INSERT INTO "_settings__system" (${cols.map((c) => `"${c}"`).join(', ')}) ` +
|
|
424
|
+
`VALUES (${cols.map(() => '?').join(', ')})`, values);
|
|
425
|
+
log.info(`[system-migration] settings moved from _settings__core to _settings__system`);
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'core-state-to-system',
|
|
430
|
+
up: async (em) => {
|
|
431
|
+
const conn = em.getConnection();
|
|
432
|
+
if (!(await introspectTable(em, '_plugin_state')).exists)
|
|
433
|
+
return;
|
|
434
|
+
await conn.execute(`UPDATE "_plugin_state" SET "plugin" = 'system' ` +
|
|
435
|
+
`WHERE "plugin" = 'core' ` +
|
|
436
|
+
`AND "key" NOT IN (SELECT "key" FROM "_plugin_state" WHERE "plugin" = 'system')`);
|
|
437
|
+
await conn.execute(`DELETE FROM "_plugin_state" WHERE "plugin" = 'core'`);
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: 'drop-legacy-core-settings',
|
|
442
|
+
up: async (em) => {
|
|
443
|
+
const conn = em.getConnection();
|
|
444
|
+
if (!(await introspectTable(em, '_settings__core')).exists)
|
|
445
|
+
return;
|
|
446
|
+
if (!(await introspectTable(em, '_settings__system')).exists)
|
|
447
|
+
return;
|
|
448
|
+
const landed = (await conn.execute(`SELECT 1 FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
|
|
449
|
+
if (landed.length === 0)
|
|
450
|
+
return;
|
|
451
|
+
await conn.execute(`DROP TABLE IF EXISTS "_settings__core"`);
|
|
452
|
+
log.info('[system-migration] dropped the legacy _settings__core table');
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'orchestrator-settings-to-system',
|
|
457
|
+
up: async (em) => {
|
|
458
|
+
const DEFAULTS = Object.fromEntries(fieldEntries(SYSTEM_SETTINGS.fields)
|
|
459
|
+
.filter(([k]) => LEGACY_ORCHESTRATOR_FIELDS.includes(k))
|
|
460
|
+
.map(([k, f]) => [k, f.default]));
|
|
461
|
+
const conn = em.getConnection();
|
|
462
|
+
const src = await introspectTable(em, '_settings__orchestrator');
|
|
463
|
+
const dst = await introspectTable(em, '_settings__system');
|
|
464
|
+
if (!src.exists || !dst.exists)
|
|
465
|
+
return;
|
|
466
|
+
const carried = LEGACY_ORCHESTRATOR_FIELDS.filter((c) => src.columns.has(c) && dst.columns.has(c));
|
|
467
|
+
if (carried.length === 0)
|
|
468
|
+
return;
|
|
469
|
+
const rows = (await conn.execute(`SELECT * FROM "_settings__orchestrator" WHERE "plugin_id" = 'orchestrator' LIMIT 1`));
|
|
470
|
+
const row = rows[0];
|
|
471
|
+
if (!row)
|
|
472
|
+
return;
|
|
473
|
+
let wrote = false;
|
|
474
|
+
for (const c of carried) {
|
|
475
|
+
const value = row[c];
|
|
476
|
+
if (value === undefined || value === null)
|
|
477
|
+
continue;
|
|
478
|
+
const def = DEFAULTS[c];
|
|
479
|
+
const whereExtra = def === undefined ? `${q(c)} IS NULL` : `(${q(c)} IS NULL OR ${q(c)} = ?)`;
|
|
480
|
+
const params = def === undefined ? [value] : [value, def];
|
|
481
|
+
await conn.execute(`UPDATE "_settings__system" SET ${q(c)} = ? WHERE "plugin_id" = 'system' AND ${whereExtra}`, params);
|
|
482
|
+
wrote = true;
|
|
483
|
+
}
|
|
484
|
+
if (wrote)
|
|
485
|
+
log.info('[system-migration] orchestrator settings carried into _settings__system');
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: 'drop-legacy-orchestrator-settings',
|
|
490
|
+
up: async (em) => {
|
|
491
|
+
const conn = em.getConnection();
|
|
492
|
+
const src = await introspectTable(em, '_settings__orchestrator');
|
|
493
|
+
const dst = await introspectTable(em, '_settings__system');
|
|
494
|
+
if (!src.exists || !dst.exists)
|
|
495
|
+
return;
|
|
496
|
+
const fields = LEGACY_ORCHESTRATOR_FIELDS;
|
|
497
|
+
const srcRows = (await conn.execute(`SELECT * FROM "_settings__orchestrator" WHERE "plugin_id" = 'orchestrator' LIMIT 1`));
|
|
498
|
+
const srcRow = srcRows[0];
|
|
499
|
+
const needsLanding = fields.filter((c) => src.columns.has(c) && srcRow?.[c] !== undefined && srcRow?.[c] !== null);
|
|
500
|
+
if (needsLanding.length > 0) {
|
|
501
|
+
const dstRows = (await conn.execute(`SELECT * FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
|
|
502
|
+
const dstRow = dstRows[0];
|
|
503
|
+
const landed = needsLanding.every((c) => dst.columns.has(c) && dstRow?.[c] !== undefined && dstRow?.[c] !== null);
|
|
504
|
+
if (!landed)
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await conn.execute(`DROP TABLE IF EXISTS "_settings__orchestrator"`);
|
|
508
|
+
log.info('[system-migration] dropped the legacy _settings__orchestrator table');
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
export async function runSystemMigrations(em) {
|
|
513
|
+
for (const m of SYSTEM_MIGRATIONS) {
|
|
514
|
+
await m.up(em);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
395
517
|
export function validateMigrations(pluginId, list) {
|
|
396
518
|
const versions = list.map((m) => m.version).sort((a, b) => a - b);
|
|
397
519
|
versions.forEach((v, i) => {
|
package/dist/mutate.js
CHANGED
|
@@ -1,35 +1,70 @@
|
|
|
1
1
|
import { serialize } from '@mikro-orm/core';
|
|
2
|
-
import { buildZodObject, buildZodObjectPartial, fieldEntries } from '@coffer-org/sdk/shelf';
|
|
2
|
+
import { buildZodObject, buildZodObjectPartial, fieldEntries, collectionGroups } from '@coffer-org/sdk/shelf';
|
|
3
3
|
import { isJsonStored, decodeVmsg } from '@coffer-org/sdk/fields';
|
|
4
4
|
import { resolveFlag } from '@coffer-org/sdk/condition';
|
|
5
|
-
import { applyDerived } from '@coffer-org/sdk/derive';
|
|
6
5
|
import { getEm } from "./db.js";
|
|
7
6
|
import { selectRows } from "./read-rows.js";
|
|
8
7
|
import { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from "./file-fields.js";
|
|
9
8
|
import { notifyRecordsChanged } from "./index-signal.js";
|
|
10
9
|
import { encodeTemporal, decodeTemporal } from "./temporal.js";
|
|
11
|
-
import { splitCollections, writeCollections, deleteCollections, flattenEmbedded, nestEmbedded, readCollections, } from "./collection-io.js";
|
|
12
|
-
import {
|
|
10
|
+
import { splitCollections, writeCollections, deleteCollections, flattenEmbedded, flattenEmbeddedAt, nestEmbedded, readCollections, readCollectionsWithMeta, } from "./collection-io.js";
|
|
11
|
+
import { stripClockKeysDeep, clockValuesOf } from "./cache-clock.js";
|
|
12
|
+
import { refreshCachedUnits } from "./cache-refresh.js";
|
|
13
|
+
import { hasStoredPartWork, newPartMemo } from "./part-injection.js";
|
|
14
|
+
import { changedFields, runTriggers } from "./trigger-queue.js";
|
|
15
|
+
import { getActiveRegistry } from "./registry-context.js";
|
|
16
|
+
import { sameValue } from "./value-compare.js";
|
|
13
17
|
const EMPTY_SETTINGS = {};
|
|
14
18
|
const EMPTY_GLOBAL = {};
|
|
15
19
|
function nowIso() {
|
|
16
20
|
return new Date().toISOString();
|
|
17
21
|
}
|
|
18
|
-
function partCtx(m, memo, em) {
|
|
22
|
+
function partCtx(m, memo, now, em) {
|
|
19
23
|
return {
|
|
20
24
|
settings: EMPTY_SETTINGS,
|
|
21
25
|
global: EMPTY_GLOBAL,
|
|
22
26
|
meta: { library: m.library, shelf: m.shelf },
|
|
23
27
|
...(memo ? { memo } : {}),
|
|
24
28
|
...(em ? { em } : {}),
|
|
29
|
+
...(now !== undefined ? { now } : {}),
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
|
-
async function
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
async function driveTriggers(m, before, after, memo, now, em, opts = {}) {
|
|
33
|
+
const changed = changedFields(before, after, m.fields);
|
|
34
|
+
const graph = getActiveRegistry().mutateGraph;
|
|
35
|
+
const shelfKey = `${m.library}/${m.shelf}`;
|
|
36
|
+
const preTrigger = { ...after };
|
|
37
|
+
const touched = [];
|
|
38
|
+
const triggered = await runTriggers(m.fields, after, changed, graph, shelfKey, partCtx(m, memo, now, em), {
|
|
39
|
+
...opts,
|
|
40
|
+
touched,
|
|
41
|
+
});
|
|
42
|
+
Object.assign(after, triggered);
|
|
43
|
+
return { changed: changedFields(preTrigger, after, m.fields).map((c) => c.path.join('.')), touched };
|
|
44
|
+
}
|
|
45
|
+
async function persistUntouchedCollectionDiffs(tx, m, id, patchedKeys, before, beforeMeta, after) {
|
|
46
|
+
for (const c of collectionGroups(m.fields)) {
|
|
47
|
+
if (patchedKeys.has(c.key))
|
|
48
|
+
continue;
|
|
49
|
+
const beforeRows = before[c.key] ?? [];
|
|
50
|
+
const afterRows = after[c.key] ?? [];
|
|
51
|
+
const metas = beforeMeta[c.key] ?? [];
|
|
52
|
+
const childFields = c.group.fields;
|
|
53
|
+
const table = `${m.library}__${m.shelf}__${c.key}`;
|
|
54
|
+
const n = Math.min(beforeRows.length, afterRows.length, metas.length);
|
|
55
|
+
for (let i = 0; i < n; i++) {
|
|
56
|
+
const rowId = metas[i].id;
|
|
57
|
+
const flatBefore = encodeJsonAt(childFields, flattenEmbeddedAt(childFields, beforeRows[i]));
|
|
58
|
+
const flatAfter = encodeJsonAt(childFields, flattenEmbeddedAt(childFields, afterRows[i]));
|
|
59
|
+
const rowPatch = {};
|
|
60
|
+
for (const [k, v] of Object.entries(flatAfter)) {
|
|
61
|
+
if (!sameValue(flatBefore[k] ?? null, v ?? null))
|
|
62
|
+
rowPatch[k] = v ?? null;
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(rowPatch).length > 0)
|
|
65
|
+
await tx.nativeUpdate(table, { id: rowId }, rowPatch);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
33
68
|
}
|
|
34
69
|
export function encodeJsonAt(fields, data) {
|
|
35
70
|
const out = { ...data };
|
|
@@ -84,10 +119,11 @@ export async function getRecord(m, entityName, id, opts = {}) {
|
|
|
84
119
|
if (!row)
|
|
85
120
|
return undefined;
|
|
86
121
|
const flat = decodeTemporal(m, row);
|
|
122
|
+
const clocks = clockValuesOf(flat);
|
|
87
123
|
const nested = nestEmbedded(m, flat);
|
|
88
|
-
const collections = await
|
|
124
|
+
const { collections, meta } = await readCollectionsWithMeta(fork, m, id);
|
|
89
125
|
const record = { ...nested, ...collections };
|
|
90
|
-
return
|
|
126
|
+
return refreshCachedUnits(m, entityName, record, clocks, meta, partCtx(m));
|
|
91
127
|
}
|
|
92
128
|
export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
93
129
|
const parsed = buildZodObject(m).safeParse(input);
|
|
@@ -99,7 +135,9 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
99
135
|
if (fileIssues.length)
|
|
100
136
|
throw new ValidationError(fileIssues);
|
|
101
137
|
const memo = newPartMemo();
|
|
102
|
-
|
|
138
|
+
const triggerResult = hasStoredPartWork(m.fields)
|
|
139
|
+
? await driveTriggers(m, {}, parsedData, memo, ts, undefined, { seedAll: true })
|
|
140
|
+
: { changed: [], touched: [] };
|
|
103
141
|
const { base, collections } = splitCollections(m, { ...parsedData });
|
|
104
142
|
const data = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...base, created_at: ts, updated_at: ts })));
|
|
105
143
|
let id;
|
|
@@ -110,21 +148,16 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
110
148
|
await tx.flush();
|
|
111
149
|
id = entity.id;
|
|
112
150
|
await writeCollections(tx, m, id, collections);
|
|
113
|
-
const derived = await applyDerived(m.fields, { ...parsedData, id });
|
|
114
|
-
if (Object.keys(derived).length) {
|
|
115
|
-
await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
|
|
116
|
-
Object.assign(parsedData, derived);
|
|
117
|
-
}
|
|
118
151
|
const _extends = afterBase ? await afterBase(tx, id) : undefined;
|
|
119
|
-
writeEvent(tx, ctx.actor, 'create', `${m.library}/${m.shelf}`, id, null, {
|
|
120
|
-
...parsedData,
|
|
121
|
-
id,
|
|
122
|
-
...(_extends ? { _extends } : {}),
|
|
123
|
-
});
|
|
152
|
+
writeEvent(tx, ctx.actor, 'create', `${m.library}/${m.shelf}`, id, null, stripClockKeysDeep(m.fields, { ...parsedData, id, ...(_extends ? { _extends } : {}) }));
|
|
124
153
|
});
|
|
125
154
|
notifyRecordsChanged();
|
|
126
155
|
const record = { ...parsedData, id, created_at: ts, updated_at: ts };
|
|
127
|
-
return
|
|
156
|
+
return {
|
|
157
|
+
...stripClockKeysDeep(m.fields, record),
|
|
158
|
+
changed: triggerResult.changed,
|
|
159
|
+
...(triggerResult.touched.length ? { changedRecords: triggerResult.touched } : {}),
|
|
160
|
+
};
|
|
128
161
|
}
|
|
129
162
|
export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
130
163
|
let patch = input;
|
|
@@ -141,6 +174,7 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
141
174
|
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
142
175
|
const memo = newPartMemo();
|
|
143
176
|
let result;
|
|
177
|
+
let triggerResult = { changed: [], touched: [] };
|
|
144
178
|
await getEm()
|
|
145
179
|
.fork()
|
|
146
180
|
.transactional(async (tx) => {
|
|
@@ -158,7 +192,7 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
158
192
|
const merged = { ...existing, ...base };
|
|
159
193
|
const reqIssues = [];
|
|
160
194
|
for (const [key, f] of fieldEntries(m.fields)) {
|
|
161
|
-
if (f.
|
|
195
|
+
if (f.required === true)
|
|
162
196
|
continue;
|
|
163
197
|
if (key.includes('__'))
|
|
164
198
|
continue;
|
|
@@ -170,30 +204,37 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
170
204
|
}
|
|
171
205
|
if (reqIssues.length)
|
|
172
206
|
throw new ValidationError(reqIssues);
|
|
173
|
-
const
|
|
207
|
+
const storedWithMeta = hasStoredPartWork(m.fields)
|
|
208
|
+
? await readCollectionsWithMeta(tx, m, id)
|
|
209
|
+
: { collections: {}, meta: {} };
|
|
210
|
+
const storedCollections = storedWithMeta.collections;
|
|
174
211
|
const target = { ...merged, ...storedCollections, ...collections };
|
|
175
|
-
if (
|
|
212
|
+
if (hasStoredPartWork(m.fields)) {
|
|
213
|
+
const before = { ...existing, ...storedCollections };
|
|
214
|
+
triggerResult = await driveTriggers(m, before, target, memo, ts, tx);
|
|
176
215
|
const resplit = splitCollections(m, target);
|
|
177
216
|
Object.assign(merged, resplit.base);
|
|
178
|
-
|
|
217
|
+
const patchedKeys = new Set(Object.keys(collections));
|
|
218
|
+
for (const k of patchedKeys) {
|
|
179
219
|
const rows = resplit.collections[k];
|
|
180
220
|
if (rows)
|
|
181
221
|
collections[k] = rows;
|
|
182
222
|
}
|
|
223
|
+
await persistUntouchedCollectionDiffs(tx, m, id, patchedKeys, storedCollections, storedWithMeta.meta, resplit.collections);
|
|
183
224
|
}
|
|
184
225
|
const dbRow = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...merged, updated_at: ts })));
|
|
185
226
|
await tx.upsert(entityName, dbRow);
|
|
186
227
|
await writeCollections(tx, m, id, collections);
|
|
187
228
|
const allCollections = await readCollections(tx, m, id);
|
|
188
|
-
const derived = await applyDerived(m.fields, { ...merged, ...allCollections });
|
|
189
|
-
if (Object.keys(derived).length) {
|
|
190
|
-
await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
|
|
191
|
-
Object.assign(merged, derived);
|
|
192
|
-
}
|
|
193
229
|
const _extends = afterBase ? await afterBase(tx, id) : undefined;
|
|
194
230
|
const after = { ...merged, ...allCollections, updated_at: ts, ...(_extends ? { _extends } : {}) };
|
|
231
|
+
stripClockKeysDeep(m.fields, after);
|
|
195
232
|
writeEvent(tx, ctx.actor, 'update', `${m.library}/${m.shelf}`, id, existing, after);
|
|
196
|
-
result =
|
|
233
|
+
result = {
|
|
234
|
+
...after,
|
|
235
|
+
changed: triggerResult.changed,
|
|
236
|
+
...(triggerResult.touched.length ? { changedRecords: triggerResult.touched } : {}),
|
|
237
|
+
};
|
|
197
238
|
});
|
|
198
239
|
notifyRecordsChanged();
|
|
199
240
|
return result;
|
|
@@ -228,7 +269,7 @@ export async function restoreRecord(m, entityName, id, ctx, afterBase) {
|
|
|
228
269
|
const _extends = afterBase ? await afterBase(tx, id) : undefined;
|
|
229
270
|
const restored = { ...nested, ...collections, ...(_extends ? { _extends } : {}) };
|
|
230
271
|
writeEvent(tx, ctx.actor, 'restore', `${m.library}/${m.shelf}`, id, null, restored);
|
|
231
|
-
result =
|
|
272
|
+
result = restored;
|
|
232
273
|
});
|
|
233
274
|
notifyRecordsChanged();
|
|
234
275
|
return result;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { collectMcpTools } from "../mcp-tools.js";
|
|
2
|
+
import { LocalClient } from "../mcp-local.js";
|
|
3
|
+
import { inspectUpload } from "./file-inspection.js";
|
|
4
|
+
const ok = (data) => ({
|
|
5
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
6
|
+
});
|
|
7
|
+
const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
8
|
+
function textOf(result) {
|
|
9
|
+
const block = result.content.find((b) => b.type === 'text');
|
|
10
|
+
return block?.type === 'text' ? block.text : '';
|
|
11
|
+
}
|
|
12
|
+
function resultData(result) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(textOf(result));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function fieldType(items, key) {
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
if (!item || typeof item !== 'object')
|
|
23
|
+
continue;
|
|
24
|
+
const raw = item;
|
|
25
|
+
if (raw.key === key && raw.type)
|
|
26
|
+
return raw.type;
|
|
27
|
+
if (raw.kind === 'group' && Array.isArray(raw.fields)) {
|
|
28
|
+
const nested = fieldType(raw.fields, key);
|
|
29
|
+
if (nested)
|
|
30
|
+
return nested;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
async function findFileField(library, shelf, key) {
|
|
36
|
+
const schema = (await new LocalClient().getSchema());
|
|
37
|
+
const lib = schema.find((item) => item.id === library);
|
|
38
|
+
const sh = lib?.shelves?.find((item) => item.shelf === shelf);
|
|
39
|
+
return sh?.fields ? fieldType(sh.fields, key) : undefined;
|
|
40
|
+
}
|
|
41
|
+
function parseStoredValue(value) {
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
return value;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(value);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function fileItems(value) {
|
|
52
|
+
const parsed = parseStoredValue(value);
|
|
53
|
+
if (Array.isArray(parsed))
|
|
54
|
+
return parsed.filter((item) => !!item && typeof item === 'object');
|
|
55
|
+
return parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
56
|
+
}
|
|
57
|
+
function findTool(defs, name) {
|
|
58
|
+
return defs.find((def) => def.server === 'coffer' && def.bareName === name);
|
|
59
|
+
}
|
|
60
|
+
export async function makeAttachmentCapabilities(attachments) {
|
|
61
|
+
const defs = await collectMcpTools();
|
|
62
|
+
const getRecord = findTool(defs, 'get_record');
|
|
63
|
+
const updateRecord = findTool(defs, 'update_record');
|
|
64
|
+
const tools = [];
|
|
65
|
+
if (getRecord) {
|
|
66
|
+
tools.push({
|
|
67
|
+
name: 'mcp__coffer__inspect_record_file',
|
|
68
|
+
description: 'Read an existing file/image/PDF/text item stored in a Coffer record. ' +
|
|
69
|
+
'Call get_record first, then pass the exact file name from the field value. ' +
|
|
70
|
+
'Do not use local paths or URLs. If a field contains multiple files, file_name is required.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
library: { type: 'string', description: 'Library identifier, e.g. things' },
|
|
73
|
+
shelf: { type: 'string', description: 'Shelf identifier, e.g. item' },
|
|
74
|
+
id: { type: 'integer', description: 'Record id' },
|
|
75
|
+
field: { type: 'string', description: 'File/image/document field key' },
|
|
76
|
+
file_name: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'Exact stored file name from get_record; required for multiple files',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
handler: async (args) => {
|
|
82
|
+
const library = String(args['library'] ?? '');
|
|
83
|
+
const shelf = String(args['shelf'] ?? '');
|
|
84
|
+
const id = Number(args['id']);
|
|
85
|
+
const field = String(args['field'] ?? '');
|
|
86
|
+
if (!library || !shelf || !field || !Number.isInteger(id))
|
|
87
|
+
return fail('library, shelf, field and integer id are required');
|
|
88
|
+
const type = await findFileField(library, shelf, field);
|
|
89
|
+
if (!type || type.prim !== 'file')
|
|
90
|
+
return fail(`Field is not a file field: ${library}/${shelf}.${field}`);
|
|
91
|
+
const currentResult = await getRecord.handler({ library, shelf, id });
|
|
92
|
+
if (currentResult.isError)
|
|
93
|
+
return currentResult;
|
|
94
|
+
const current = resultData(currentResult);
|
|
95
|
+
if (!current || typeof current !== 'object')
|
|
96
|
+
return fail(`Record not found: ${library}/${shelf}/${id}`);
|
|
97
|
+
const existing = fileItems(current[field]);
|
|
98
|
+
if (!existing.length)
|
|
99
|
+
return fail(`Record field is empty: ${library}/${shelf}/${id}.${field}`);
|
|
100
|
+
const requested = typeof args['file_name'] === 'string' ? args['file_name'] : '';
|
|
101
|
+
const item = requested
|
|
102
|
+
? existing.find((entry) => entry['name'] === requested)
|
|
103
|
+
: existing.length === 1
|
|
104
|
+
? existing[0]
|
|
105
|
+
: undefined;
|
|
106
|
+
if (!item || typeof item['name'] !== 'string') {
|
|
107
|
+
const available = existing
|
|
108
|
+
.map((entry) => String(entry['name'] ?? ''))
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(', ');
|
|
111
|
+
return fail(existing.length > 1
|
|
112
|
+
? `Multiple files are stored in this field; file_name is required. Available: ${available}`
|
|
113
|
+
: `Stored file was not found in record field: ${requested}`);
|
|
114
|
+
}
|
|
115
|
+
return inspectUpload(item['name'], {
|
|
116
|
+
...(typeof item['mime'] === 'string' ? { mime: item['mime'] } : {}),
|
|
117
|
+
...(typeof item['size'] === 'number' ? { size: item['size'] } : {}),
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (!attachments.length || !getRecord || !updateRecord)
|
|
123
|
+
return { tools };
|
|
124
|
+
const attach = {
|
|
125
|
+
name: 'mcp__coffer__attach_conversation_attachments',
|
|
126
|
+
description: 'Attach files already received in the current conversation to a file/image/document field in a Coffer record. ' +
|
|
127
|
+
'Use attachment_names for selected files, or omit it to attach every current-conversation attachment. ' +
|
|
128
|
+
'The default mode appends to a multiple field; use mode="replace" only when explicitly requested. ' +
|
|
129
|
+
'This is the unified attachment tool for every connector — never use a local path or invent an upload name.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
library: { type: 'string', description: 'Library identifier, e.g. things' },
|
|
132
|
+
shelf: { type: 'string', description: 'Shelf identifier, e.g. item' },
|
|
133
|
+
id: { type: 'integer', description: 'Record id' },
|
|
134
|
+
field: { type: 'string', description: 'File/image/document field key' },
|
|
135
|
+
attachment_names: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
items: { type: 'string' },
|
|
138
|
+
description: 'Exact server-generated names from the current conversation; omit to use all attachments',
|
|
139
|
+
},
|
|
140
|
+
mode: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
enum: ['append', 'replace'],
|
|
143
|
+
description: 'append by default; replace only on explicit request',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
handler: async (args) => {
|
|
147
|
+
const library = String(args['library'] ?? '');
|
|
148
|
+
const shelf = String(args['shelf'] ?? '');
|
|
149
|
+
const id = Number(args['id']);
|
|
150
|
+
const field = String(args['field'] ?? '');
|
|
151
|
+
const mode = args['mode'] === 'replace' ? 'replace' : 'append';
|
|
152
|
+
if (!library || !shelf || !field || !Number.isInteger(id))
|
|
153
|
+
return fail('library, shelf, field and integer id are required');
|
|
154
|
+
const names = Array.isArray(args['attachment_names'])
|
|
155
|
+
? args['attachment_names'].filter((name) => typeof name === 'string')
|
|
156
|
+
: attachments.map((item) => item.name);
|
|
157
|
+
const uniqueNames = [...new Set(names)];
|
|
158
|
+
const refs = uniqueNames.map((name) => attachments.find((item) => item.name === name));
|
|
159
|
+
if (refs.some((ref) => !ref)) {
|
|
160
|
+
const missing = uniqueNames.filter((name) => !attachments.some((item) => item.name === name));
|
|
161
|
+
return fail(`Attachment is not available in the current conversation: ${missing.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
if (!refs.length)
|
|
164
|
+
return fail('No conversation attachments were selected');
|
|
165
|
+
const type = await findFileField(library, shelf, field);
|
|
166
|
+
if (!type || type.prim !== 'file')
|
|
167
|
+
return fail(`Field is not a file field: ${library}/${shelf}.${field}`);
|
|
168
|
+
const multiple = type.hints?.multiple === true;
|
|
169
|
+
const currentResult = await getRecord.handler({ library, shelf, id });
|
|
170
|
+
if (currentResult.isError)
|
|
171
|
+
return currentResult;
|
|
172
|
+
const current = resultData(currentResult);
|
|
173
|
+
if (!current || typeof current !== 'object')
|
|
174
|
+
return fail(`Record not found: ${library}/${shelf}/${id}`);
|
|
175
|
+
const existing = fileItems(current[field]);
|
|
176
|
+
const incoming = refs.map((ref) => ({ name: ref.name }));
|
|
177
|
+
if (!multiple && mode === 'append' && existing.length)
|
|
178
|
+
return fail(`Field already contains a file; use mode="replace" to replace it`);
|
|
179
|
+
if (!multiple && incoming.length > 1)
|
|
180
|
+
return fail(`Field accepts one file; select one attachment or use a multiple field`);
|
|
181
|
+
const next = mode === 'replace'
|
|
182
|
+
? incoming
|
|
183
|
+
: [...existing, ...incoming.filter((item) => !existing.some((old) => old.name === item.name))];
|
|
184
|
+
const value = multiple ? next : (next[0] ?? null);
|
|
185
|
+
const updated = await updateRecord.handler({ library, shelf, id, fields: { [field]: value } });
|
|
186
|
+
if (updated.isError)
|
|
187
|
+
return updated;
|
|
188
|
+
return ok({
|
|
189
|
+
attached: incoming.map((item) => item.name),
|
|
190
|
+
mode,
|
|
191
|
+
library,
|
|
192
|
+
shelf,
|
|
193
|
+
id,
|
|
194
|
+
field,
|
|
195
|
+
record: resultData(updated),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
tools.push(attach);
|
|
200
|
+
return { tools };
|
|
201
|
+
}
|