@coffer-org/server 2.5.2 → 2.6.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.
@@ -2,6 +2,7 @@ import { serialize } from '@mikro-orm/core';
2
2
  import { collectionGroups, fieldEntries, storageColumns } from '@coffer-org/sdk/shelf';
3
3
  import { isGroup, isEmbeddedGroup, isJsonStored } from '@coffer-org/sdk/fields';
4
4
  import { chunk } from "./batch.js";
5
+ import { selectRows } from "./read-rows.js";
5
6
  function flattenLayout(fields) {
6
7
  const out = [];
7
8
  for (const f of fields) {
@@ -68,7 +69,7 @@ export async function readAt(em, prefix, fields, parentId) {
68
69
  for (const c of collectionGroups(fields)) {
69
70
  const table = `${prefix}__${c.key}`;
70
71
  const childFields = c.group.fields;
71
- const rows = serialize(await em.find(table, { parent_id: parentId }, { orderBy: { position: 'asc' } }));
72
+ const rows = (await selectRows(em, table, { parent_id: parentId }, { orderBy: { position: 'asc' } }));
72
73
  const acc = [];
73
74
  for (const row of rows) {
74
75
  const { id, parent_id: _pid, position: _pos, ...rest } = row;
@@ -90,9 +91,7 @@ export async function readAtMany(em, prefix, fields, parentIds) {
90
91
  const childFields = c.group.fields;
91
92
  const rows = [];
92
93
  for (const ids of chunk(parentIds)) {
93
- rows.push(...serialize(await em.find(table, { parent_id: { $in: ids } }, {
94
- orderBy: { position: 'asc' },
95
- })));
94
+ rows.push(...(await selectRows(em, table, { parent_id: { $in: ids } }, { orderBy: { position: 'asc' } })));
96
95
  }
97
96
  const nestedByChild = await readAtMany(em, table, childFields, rows.map((r) => r.id));
98
97
  for (const id of parentIds)
@@ -25,6 +25,7 @@ export declare function cosine(a: ArrayLike<number>, b: ArrayLike<number>): numb
25
25
  export declare function twoPassSearch(rows: EmbeddingRow[], queryVec: ArrayLike<number>, k: number, opts?: {
26
26
  coarseDims?: number;
27
27
  poolMinFactor?: number;
28
+ shelfKeys?: Set<string>;
28
29
  }): EmbeddingHit[];
29
30
  export declare function upsertEmbedding(args: {
30
31
  shelfKey: string;
@@ -34,5 +35,7 @@ export declare function upsertEmbedding(args: {
34
35
  model: string;
35
36
  }): Promise<void>;
36
37
  export declare function deleteEmbedding(shelfKey: string, recordId: number): Promise<void>;
37
- export declare function searchEmbeddings(queryVec: number[], k: number): Promise<EmbeddingHit[]>;
38
+ export declare function searchEmbeddings(queryVec: number[], k: number, opts?: {
39
+ shelfKeys?: Set<string>;
40
+ }): Promise<EmbeddingHit[]>;
38
41
  export declare function listEventsSince(lastId: number, limit: number): Promise<EventRow[]>;
@@ -59,7 +59,8 @@ export function cosine(a, b) {
59
59
  return denom ? dot / denom : 0;
60
60
  }
61
61
  export function twoPassSearch(rows, queryVec, k, opts = {}) {
62
- const N = rows.length;
62
+ const scope = opts.shelfKeys ? rows.filter((r) => opts.shelfKeys.has(r.shelfKey)) : rows;
63
+ const N = scope.length;
63
64
  if (N === 0)
64
65
  return [];
65
66
  const coarseDims = opts.coarseDims ?? COARSE_DIMS;
@@ -68,13 +69,13 @@ export function twoPassSearch(rows, queryVec, k, opts = {}) {
68
69
  const m = Math.min(coarseDims, qFull.length);
69
70
  const qSmall = qFull.subarray(0, m);
70
71
  const poolSize = Math.min(N, Math.max(k * poolMinFactor, Math.ceil(N * 0.1)));
71
- const pool = rows
72
+ const pool = scope
72
73
  .map((r, i) => ({ i, d: 1 - cosine(r.full.subarray(0, m), qSmall) }))
73
74
  .sort((a, b) => a.d - b.d)
74
75
  .slice(0, poolSize);
75
76
  return pool
76
77
  .map(({ i }) => {
77
- const r = rows[i];
78
+ const r = scope[i];
78
79
  return { shelfKey: r.shelfKey, recordId: r.recordId, snippet: r.snippet, distance: 1 - cosine(r.full, qFull) };
79
80
  })
80
81
  .sort((a, b) => a.distance - b.distance)
@@ -99,8 +100,8 @@ export async function deleteEmbedding(shelfKey, recordId) {
99
100
  await em.nativeDelete('_Embedding', { shelf_key: shelfKey, record_id: recordId });
100
101
  invalidateEmbeddingCache();
101
102
  }
102
- export async function searchEmbeddings(queryVec, k) {
103
- return twoPassSearch(await loadCache(), queryVec, k);
103
+ export async function searchEmbeddings(queryVec, k, opts = {}) {
104
+ return twoPassSearch(await loadCache(), queryVec, k, opts);
104
105
  }
105
106
  export async function listEventsSince(lastId, limit) {
106
107
  const em = getEm().fork();
@@ -1,17 +1,16 @@
1
- import { serialize } from '@mikro-orm/core';
2
1
  import { getEm } from "./db.js";
3
2
  import { splitAt, writeAt, readAt, readAtMany, deleteAt } from "./collection-io.js";
4
3
  import { chunk } from "./batch.js";
4
+ import { selectRows } from "./read-rows.js";
5
5
  export function extendEntityName(e) {
6
6
  return `extend__${e.id}`;
7
7
  }
8
8
  export async function getExtendRecord(e, baseId, em) {
9
9
  const fork = em ?? getEm().fork();
10
10
  const name = extendEntityName(e);
11
- const row = await fork.findOne(name, { base_id: baseId });
12
- if (!row)
11
+ const [flat] = (await selectRows(fork, name, { base_id: baseId }, { limit: 1 }));
12
+ if (!flat)
13
13
  return undefined;
14
- const flat = serialize(row);
15
14
  const collections = await readAt(fork, name, e.fields, baseId);
16
15
  return { ...flat, ...collections };
17
16
  }
@@ -23,7 +22,7 @@ export async function getExtendRecords(e, baseIds, em) {
23
22
  const name = extendEntityName(e);
24
23
  const flats = [];
25
24
  for (const ids of chunk(baseIds)) {
26
- flats.push(...serialize(await fork.find(name, { base_id: { $in: ids } })));
25
+ flats.push(...(await selectRows(fork, name, { base_id: { $in: ids } })));
27
26
  }
28
27
  if (flats.length === 0)
29
28
  return out;
@@ -1,10 +1,10 @@
1
1
  import { textSearchKeys, titleKey, recordTitle, storageColumnsFor } from '@coffer-org/sdk/shelf';
2
- import { serialize } from '@mikro-orm/core';
3
2
  import { tokenize } from '@coffer-org/core/search';
4
3
  import { getLogger } from '@coffer-org/sdk/logger';
5
4
  import { getEm } from "./db.js";
6
5
  import { rowMatch } from "./records-api.js";
7
6
  import { ftsAvailable, ftsCandidates, MIN_FTS_QUERY_LENGTH } from "./search-index.js";
7
+ import { selectRows } from "./read-rows.js";
8
8
  const log = getLogger('search');
9
9
  export const SEARCH_SCAN_LIMIT = 1000;
10
10
  function matchCols(m) {
@@ -44,10 +44,7 @@ export async function globalSearchByScan(shelves, q, limit) {
44
44
  continue;
45
45
  if (!textSearchKeys(shelf.def).length)
46
46
  continue;
47
- const rows = (await fork.find(shelf.table, {}, {
48
- limit: SEARCH_SCAN_LIMIT,
49
- fields: matchCols(shelf.def),
50
- })).map((r) => serialize(r));
47
+ const rows = await selectRows(fork, shelf.table, {}, { limit: SEARCH_SCAN_LIMIT, fields: matchCols(shelf.def) });
51
48
  if (rows.length === SEARCH_SCAN_LIMIT) {
52
49
  log.warn('scan cap reached', { shelf: shelf.key, limit: SEARCH_SCAN_LIMIT });
53
50
  }
@@ -77,9 +74,7 @@ export async function globalSearch(shelves, q, limit) {
77
74
  continue;
78
75
  if (!textSearchKeys(shelf.def).length)
79
76
  continue;
80
- const rows = (await fork.find(shelf.table, { id: { $in: ids } }, {
81
- fields: matchCols(shelf.def),
82
- })).map((r) => serialize(r));
77
+ const rows = await selectRows(fork, shelf.table, { id: { $in: ids } }, { fields: matchCols(shelf.def) });
83
78
  scored.push(...scoreRows(shelf, rows, tokens));
84
79
  }
85
80
  scored.sort((a, b) => b.score - a.score || tieKey(a.result).localeCompare(tieKey(b.result)));
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { baseUrl } from "./public-url.js";
24
24
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
25
25
  import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveBaseTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
26
26
  import { buildClientSchema } from "./schema-api.js";
27
- import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, } from "./records-api.js";
27
+ import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, ensureSingle, SingleShelfError, } from "./records-api.js";
28
28
  import { maskTree, preserveTree } from "./field-masking.js";
29
29
  import { writePluginSettings } from "./settings-write.js";
30
30
  import { rootLogger, getLogger } from "./log.js";
@@ -125,6 +125,8 @@ async function guard(reply, fn) {
125
125
  catch (e) {
126
126
  if (e instanceof ValidationError)
127
127
  return reply.code(422).send({ error: 'validation', issues: e.issues });
128
+ if (e instanceof SingleShelfError)
129
+ return reply.code(409).send({ error: 'single_shelf', message: e.message });
128
130
  if (e instanceof NotFoundError)
129
131
  return reply.code(404).send({ error: 'not_found' });
130
132
  app.log.error(e);
@@ -365,6 +367,11 @@ function maskRecordRow(library, shelf, row) {
365
367
  }
366
368
  return out;
367
369
  }
370
+ function listRow(library, shelf, row) {
371
+ const out = maskRecordRow(library, shelf, row);
372
+ const activity = out._activity;
373
+ return activity ? { ...out, _activity: { favorite: activity.favorite === true } } : out;
374
+ }
368
375
  app.get('/api/trash', async (_req, reply) => {
369
376
  const rows = await recordTrashList();
370
377
  return rows.map((entry) => ({ ...entry, record: maskRecordRow(entry.library, entry.shelf, entry.record) }));
@@ -415,7 +422,7 @@ app.get('/api/:library/:shelf', async (req, reply) => {
415
422
  let rows = await recordList(library, shelf, query, opts);
416
423
  if (req.user && tracksRecordActivity(req.headers))
417
424
  rows = await applySmartRanking(req.user.id, library, shelf, rows);
418
- return rows.map((r) => maskRecordRow(library, shelf, r));
425
+ return rows.map((r) => listRow(library, shelf, r));
419
426
  }
420
427
  const pageOpts = {
421
428
  ...opts,
@@ -431,7 +438,7 @@ app.get('/api/:library/:shelf', async (req, reply) => {
431
438
  const pageOffset = Math.max(0, Number.isFinite(pageOpts.offset) ? Math.trunc(pageOpts.offset) : 0);
432
439
  page = { rows: ranked.slice(pageOffset, pageOffset + pageLimit), total: ranked.length };
433
440
  }
434
- return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
441
+ return { rows: page.rows.map((r) => listRow(library, shelf, r)), total: page.total };
435
442
  }
436
443
  catch (e) {
437
444
  if (e instanceof UnknownShelfError)
@@ -476,6 +483,15 @@ app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
476
483
  throw e;
477
484
  }
478
485
  });
486
+ app.get('/api/:library/:shelf/single', async (req, reply) => {
487
+ const { library, shelf } = req.params;
488
+ const m = getShelf(library, shelf);
489
+ if (!m)
490
+ return reply.code(404).send({ error: 'unknown_shelf' });
491
+ if (!m.single)
492
+ return reply.code(404).send({ error: 'not_single_shelf' });
493
+ return maskRecordRow(library, shelf, await ensureSingle(library, shelf));
494
+ });
479
495
  app.get('/api/:library/:shelf/:id', async (req, reply) => {
480
496
  const { library, shelf, id } = req.params;
481
497
  const rid = Number(id);
package/dist/mcp-local.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
2
- import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, } from "./records-api.js";
2
+ import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, SingleShelfError, } 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) {
@@ -7,6 +7,8 @@ export function mapError(e) {
7
7
  throw new ValidationError(e.issues ?? []);
8
8
  if (e instanceof UnknownShelfError)
9
9
  throw new NotFoundError('not_found');
10
+ if (e instanceof SingleShelfError)
11
+ throw new ValidationError([{ field: 'shelf', code: 'single_shelf', message: e.message }]);
10
12
  throw e;
11
13
  }
12
14
  export function splitListQuery(raw) {
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { type ToolResult } from '@coffer-org/mcp';
4
4
  import { type AuthRole, type PluginHooks } from './plugin-hooks.ts';
5
5
  import { type Condition } from '@coffer-org/sdk/condition';
6
- import { type EmbeddingHit } from './embeddings.ts';
6
+ import { type RagHit } from './rag-search.ts';
7
7
  export interface McpToolDef {
8
8
  server: string;
9
9
  bareName: string;
@@ -17,7 +17,7 @@ export interface McpToolDef {
17
17
  export interface RagDeps {
18
18
  embeddingApiKey: string;
19
19
  }
20
- export declare function formatHits(hits: EmbeddingHit[]): string;
20
+ export declare function formatHits(hits: RagHit[]): string;
21
21
  export declare function resolveRagDeps(): Promise<RagDeps | null>;
22
22
  export declare function collectMcpTools(opts?: {
23
23
  rag?: RagDeps | null;
@@ -28,6 +28,19 @@ export declare function collectPluginInstructions(hooks?: Record<string, PluginH
28
28
  id: string;
29
29
  instructions: string;
30
30
  }[]>;
31
+ type SingleShelf = {
32
+ library: string;
33
+ shelf: string;
34
+ claude: string;
35
+ };
36
+ export declare function collectSingleShelves(reg?: {
37
+ shelves: {
38
+ library: string;
39
+ shelf: string;
40
+ single?: boolean;
41
+ claude?: string;
42
+ }[];
43
+ }): SingleShelf[];
31
44
  type LibraryPurpose = {
32
45
  id: string;
33
46
  agent: string;
package/dist/mcp-tools.js CHANGED
@@ -10,7 +10,8 @@ import { countTargetsFor, recordCounts } from "./counts.js";
10
10
  import { describeCondition } from '@coffer-org/sdk/condition';
11
11
  import { getEm } from "./db.js";
12
12
  import { getPluginSettings } from "./plugin-runtime.js";
13
- import { searchEmbeddings } from "./embeddings.js";
13
+ import { configuredPublicUrl } from "./public-url.js";
14
+ import { hybridSearch, buildSearchShelves } from "./rag-search.js";
14
15
  import { embedOne, embeddingFailureMessage } from "./embed-openai.js";
15
16
  import { getLogger } from "./log.js";
16
17
  import { writePluginSettings, listSettings } from "./settings-write.js";
@@ -21,7 +22,14 @@ export function formatHits(hits) {
21
22
  if (hits.length === 0)
22
23
  return 'No matching records.';
23
24
  return hits
24
- .map((h) => `[${h.shelfKey}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
25
+ .map((h) => {
26
+ const legs = [];
27
+ if (h.distance != null)
28
+ legs.push(`vector ${h.distance.toFixed(3)} #${h.vectorRank}`);
29
+ if (h.textRank != null)
30
+ legs.push(`text #${h.textRank}`);
31
+ return `[${h.shelfKey}/${h.recordId}] (${legs.join(' | ')})\n${h.snippet}`;
32
+ })
25
33
  .join('\n\n');
26
34
  }
27
35
  const ok = (data) => ({
@@ -64,11 +72,16 @@ export async function collectMcpTools(opts = {}) {
64
72
  role: 'member',
65
73
  handler: async () => {
66
74
  const { token, expiresInSec } = mintUploadTicket(actor);
75
+ const base = await configuredPublicUrl();
76
+ const uploadUrl = base ? `${base}/api/upload` : '/api/upload';
67
77
  return ok({
68
78
  token,
69
- upload_url: '/api/upload',
79
+ upload_url: uploadUrl,
70
80
  expires_in: expiresInSec,
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.',
81
+ 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
+ (base
83
+ ? ''
84
+ : ' The server has no public URL configured (core settings publicUrl / PUBLIC_URL env), so the path is relative — resolve it against this MCP server\'s own origin.'),
72
85
  });
73
86
  },
74
87
  });
@@ -132,18 +145,55 @@ export async function collectMcpTools(opts = {}) {
132
145
  server: 'rag',
133
146
  bareName: 'search_records',
134
147
  httpName: 'search_records',
135
- description: "Semantic search over the user's coffer records. Returns the most relevant records as library/shelf/id refs with a text snippet.",
136
- inputSchema: { query: z.string(), k: z.number().int().positive().optional() },
148
+ description: "Hybrid search over the user's coffer records: semantic similarity plus exact-token matching. " +
149
+ 'Returns the most relevant records as library/shelf/id refs with a text snippet. Narrow it with `library` ' +
150
+ '(and optionally `shelf`) when you already know where the answer lives. An empty result means nothing ' +
151
+ 'relevant is stored — say so instead of answering from a weak match. If the first call returns nothing ' +
152
+ 'useful, retry with a rephrasing, or with the exact literal string (a model number, a name, an error code): ' +
153
+ 'the exact-token leg finds those where a paraphrase cannot.',
154
+ inputSchema: {
155
+ query: z.string(),
156
+ k: z.number().int().positive().optional(),
157
+ library: z.string().optional(),
158
+ shelf: z.string().optional(),
159
+ },
137
160
  scope: 'rag',
138
161
  role: 'member',
139
162
  handler: async (args) => {
163
+ const library = args.library;
164
+ const shelf = args.shelf;
165
+ if (shelf && !library) {
166
+ return fail('Error: `shelf` needs `library` (a shelf name is only unique inside its library).');
167
+ }
168
+ if (library) {
169
+ const known = buildSearchShelves({ library, shelf });
170
+ if (known.length === 0) {
171
+ const all = buildSearchShelves();
172
+ const valid = shelf
173
+ ? all.filter((s) => s.key.startsWith(`${library}/`)).map((s) => s.key)
174
+ : [...new Set(all.map((s) => s.key.split('/')[0]))];
175
+ return fail(`Error: unknown ${shelf ? 'shelf' : 'library'}. Valid: ${valid.join(', ')}`);
176
+ }
177
+ }
178
+ let vector = null;
179
+ try {
180
+ vector = (await embedOne(args.query, embeddingApiKey)).vector;
181
+ }
182
+ catch (e) {
183
+ log.warn(`embedding failed, text-only search — ${embeddingFailureMessage(e)}`);
184
+ }
140
185
  try {
141
- const { vector } = await embedOne(args.query, embeddingApiKey);
142
- const hits = await searchEmbeddings(vector, args.k ?? DEFAULT_RAG_TOP_K);
186
+ const hits = await hybridSearch({
187
+ query: args.query,
188
+ vector,
189
+ k: args.k ?? DEFAULT_RAG_TOP_K,
190
+ library,
191
+ shelf,
192
+ });
143
193
  return { content: [{ type: 'text', text: formatHits(hits) }] };
144
194
  }
145
195
  catch (e) {
146
- return fail(`RAG unavailable: ${embeddingFailureMessage(e)} Use the regular coffer tools (list_records/get_record) instead.`);
196
+ return fail(`Search unavailable: ${e.message} Use the regular coffer tools (list_records/get_record) instead.`);
147
197
  }
148
198
  },
149
199
  });
@@ -214,6 +264,22 @@ export async function collectPluginInstructions(hooks = pluginHooks, emFactory =
214
264
  }
215
265
  return out;
216
266
  }
267
+ export function collectSingleShelves(reg) {
268
+ let registry = reg;
269
+ if (!registry) {
270
+ try {
271
+ registry = getActiveRegistry();
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
277
+ if (!registry)
278
+ return [];
279
+ return registry.shelves
280
+ .filter((s) => s.single && s.claude)
281
+ .map((s) => ({ library: s.library, shelf: s.shelf, claude: s.claude }));
282
+ }
217
283
  export function collectLibraryPurposes(reg) {
218
284
  let registry = reg;
219
285
  if (!registry) {
@@ -259,6 +325,13 @@ export async function buildDomainSections() {
259
325
  overview =
260
326
  '## Libraries (what each holds / when to use it — pick the right one before searching)\n\n' + blocks.join('\n\n');
261
327
  }
328
+ const singles = collectSingleShelves();
329
+ const singleSection = singles.length
330
+ ? '## Single-record shelves (one document each — read the record, do not search the shelf)\n\n' +
331
+ singles
332
+ .map((s) => `- ${s.library}/${s.shelf}: ${s.claude.replace(/\s*\n\s*/g, ' ')}`)
333
+ .join('\n')
334
+ : null;
262
335
  const dataModel = '## Data model\n' +
263
336
  'Library (top-level area) → shelf (a kind of record, e.g. things/item) → record (addressed library/shelf/id) → fields. ' +
264
337
  'Some field values are JSON (e.g. quantity {"value":2000,"unit":"ml"}); some are relations (hold another record\'s id); ' +
@@ -273,6 +346,7 @@ export async function buildDomainSections() {
273
346
  return [
274
347
  dataModel,
275
348
  ...(overview ? [overview] : []),
349
+ ...(singleSection ? [singleSection] : []),
276
350
  ...(site ? [`## web\n${site}`] : []),
277
351
  ...rules,
278
352
  ];
@@ -280,7 +354,7 @@ export async function buildDomainSections() {
280
354
  export function buildMcpInstructions(sections) {
281
355
  const base = [
282
356
  "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.",
283
- '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.',
357
+ '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 lookup — it searches by meaning and by exact token at once, and takes an optional library/shelf to narrow it. State where each fact came from by citing its record as [library/shelf/id]: an answer assembled from search results without citations cannot be checked against the data.',
284
358
  '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.',
285
359
  'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse them.',
286
360
  'Action safety: read a complete record before editing or deleting it. update_record must be the smallest patch and must preserve unmentioned fields. delete_record moves the record to reversible trash; never blank fields to simulate deletion. For suspected duplicates, read both complete records, compare fields/collections/extends/attachments, identify the less complete record, and offer a field-by-field merge before moving anything to trash. Use list_trash/restore_record for recovery; purge_record is irreversible and requires explicit confirmation.',
package/dist/mutate.js CHANGED
@@ -3,6 +3,7 @@ import { buildZodObject, buildZodObjectPartial, fieldEntries } from '@coffer-org
3
3
  import { isJsonStored, decodeVmsg } from '@coffer-org/sdk/fields';
4
4
  import { resolveFlag } from '@coffer-org/sdk/condition';
5
5
  import { getEm } from "./db.js";
6
+ import { selectRows } from "./read-rows.js";
6
7
  import { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from "./file-fields.js";
7
8
  import { notifyRecordsChanged } from "./index-signal.js";
8
9
  import { encodeTemporal, decodeTemporal } from "./temporal.js";
@@ -54,18 +55,14 @@ export async function listRecords(entityName, opts = {}) {
54
55
  const fork = getEm().fork();
55
56
  const deleted = opts.deleted ?? 'active';
56
57
  const where = deleted === 'active' ? { deleted_at: null } : deleted === 'deleted' ? { deleted_at: { $ne: null } } : {};
57
- const rows = await fork.findAll(entityName, { where, orderBy: { id: 'asc' } });
58
- return serialize(rows);
58
+ return selectRows(fork, entityName, where, { orderBy: { id: 'asc' } });
59
59
  }
60
60
  export async function getRecord(m, entityName, id, opts = {}) {
61
61
  const fork = getEm().fork();
62
- const row = await fork.findOne(entityName, {
63
- id,
64
- ...(opts.includeDeleted ? {} : { deleted_at: null }),
65
- });
62
+ const [row] = await selectRows(fork, entityName, { id, ...(opts.includeDeleted ? {} : { deleted_at: null }) }, { limit: 1 });
66
63
  if (!row)
67
64
  return undefined;
68
- const flat = decodeTemporal(m, serialize(row));
65
+ const flat = decodeTemporal(m, row);
69
66
  const nested = nestEmbedded(m, flat);
70
67
  const collections = await readCollections(fork, m, id);
71
68
  return { ...nested, ...collections };
@@ -1,4 +1,3 @@
1
- import { serialize } from '@mikro-orm/core';
2
1
  import { composeRegistry } from '@coffer-org/core/compose';
3
2
  import { getLogger } from '@coffer-org/sdk/logger';
4
3
  import { initDb, getOrm, getEm } from "./db.js";
@@ -6,6 +5,7 @@ import { syncSchema } from "./schema-sync.js";
6
5
  import { systemEntities, buildPluginEntities, shelfTableName } from "./entity-schema.js";
7
6
  import { pluginHooks, pluginCtx, HttpError } from "./plugin-hooks.js";
8
7
  import { discoverPlugins, loadServerHooks } from "./plugin-discovery.js";
8
+ import { selectRows } from "./read-rows.js";
9
9
  import { runMigrations, assertSafeRequired, renameSystemShelfKey } from "./migrations.js";
10
10
  import { runSeeds } from "./seeds.js";
11
11
  import { setActiveRegistry } from "./registry-context.js";
@@ -36,12 +36,10 @@ export function getDisabled() {
36
36
  export async function getPluginSettings(pluginId) {
37
37
  try {
38
38
  const fork = getEm().fork();
39
- const row = await fork.findOne(`_settings__${pluginId}`, {
40
- plugin_id: pluginId,
41
- });
39
+ const [row] = await selectRows(fork, `_settings__${pluginId}`, { plugin_id: pluginId }, { limit: 1 });
42
40
  if (!row)
43
41
  return {};
44
- return serialize(row);
42
+ return row;
45
43
  }
46
44
  catch {
47
45
  return {};
@@ -0,0 +1,40 @@
1
+ import { type EmbeddingHit } from './embeddings.ts';
2
+ import { type GlobalSearchHit, type SearchShelf } from './global-search.ts';
3
+ export declare const RRF_K = 60;
4
+ export declare const MAX_VECTOR_DISTANCE = 0.85;
5
+ export declare const SEARCH_POOL = 50;
6
+ export interface FusedEntry {
7
+ score: number;
8
+ ranks: (number | null)[];
9
+ }
10
+ export declare function fuseRrf<T>(lists: T[][], keyOf: (item: T) => string, k?: number): Map<string, FusedEntry>;
11
+ export interface RagHit {
12
+ shelfKey: string;
13
+ recordId: number;
14
+ snippet: string;
15
+ distance: number | null;
16
+ vectorRank: number | null;
17
+ textRank: number | null;
18
+ score: number;
19
+ }
20
+ export interface HybridOpts {
21
+ query: string;
22
+ vector: number[] | null;
23
+ k: number;
24
+ library?: string;
25
+ shelf?: string;
26
+ maxDistance?: number;
27
+ rrfK?: number;
28
+ }
29
+ export interface HybridDeps {
30
+ vectorSearch?: (vec: number[], k: number, o?: {
31
+ shelfKeys?: Set<string>;
32
+ }) => Promise<EmbeddingHit[]>;
33
+ textSearch?: (shelves: SearchShelf[], q: string, k: number) => Promise<GlobalSearchHit[]>;
34
+ shelves?: SearchShelf[];
35
+ }
36
+ export declare function buildSearchShelves(filter?: {
37
+ library?: string;
38
+ shelf?: string;
39
+ }): SearchShelf[];
40
+ export declare function hybridSearch(opts: HybridOpts, deps?: HybridDeps): Promise<RagHit[]>;
@@ -0,0 +1,95 @@
1
+ import { searchEmbeddings } from "./embeddings.js";
2
+ import { globalSearch } from "./global-search.js";
3
+ import { getActiveRegistry } from "./registry-context.js";
4
+ import { shelfTableName } from "./entity-schema.js";
5
+ import { getLogger } from "./log.js";
6
+ const log = getLogger('rag-search');
7
+ export const RRF_K = 60;
8
+ export const MAX_VECTOR_DISTANCE = 0.85;
9
+ export const SEARCH_POOL = 50;
10
+ export function fuseRrf(lists, keyOf, k = RRF_K) {
11
+ const out = new Map();
12
+ lists.forEach((list, listIndex) => {
13
+ list.forEach((item, i) => {
14
+ const key = keyOf(item);
15
+ let entry = out.get(key);
16
+ if (!entry) {
17
+ entry = { score: 0, ranks: lists.map(() => null) };
18
+ out.set(key, entry);
19
+ }
20
+ if (entry.ranks[listIndex] !== null)
21
+ return;
22
+ entry.ranks[listIndex] = i + 1;
23
+ entry.score += 1 / (k + i + 1);
24
+ });
25
+ });
26
+ return out;
27
+ }
28
+ export function buildSearchShelves(filter = {}) {
29
+ return getActiveRegistry()
30
+ .shelves.filter((m) => (filter.library ? m.library === filter.library : true))
31
+ .filter((m) => (filter.shelf ? m.shelf === filter.shelf : true))
32
+ .map((m) => ({
33
+ key: `${m.library}/${m.shelf}`,
34
+ table: shelfTableName(m.library, m.shelf),
35
+ def: m,
36
+ }));
37
+ }
38
+ const hitKey = (shelfKey, recordId) => `${shelfKey}/${recordId}`;
39
+ async function leg(name, run) {
40
+ try {
41
+ return await run();
42
+ }
43
+ catch (e) {
44
+ log.warn(`${name} leg failed, degrading — ${e.message}`);
45
+ return [];
46
+ }
47
+ }
48
+ export async function hybridSearch(opts, deps = {}) {
49
+ const vectorSearch = deps.vectorSearch ?? searchEmbeddings;
50
+ const textSearch = deps.textSearch ?? globalSearch;
51
+ const narrowed = Boolean(opts.library || opts.shelf);
52
+ const shelves = deps.shelves
53
+ ? deps.shelves
54
+ .filter((s) => (opts.library ? s.key.startsWith(`${opts.library}/`) : true))
55
+ .filter((s) => (opts.shelf ? s.key.endsWith(`/${opts.shelf}`) : true))
56
+ : buildSearchShelves({ library: opts.library, shelf: opts.shelf });
57
+ const shelfKeys = narrowed ? new Set(shelves.map((s) => s.key)) : undefined;
58
+ const maxDistance = opts.maxDistance ?? MAX_VECTOR_DISTANCE;
59
+ const [vectorHits, textHits] = await Promise.all([
60
+ opts.vector
61
+ ? leg('vector', () => vectorSearch(opts.vector, SEARCH_POOL, { shelfKeys }))
62
+ : Promise.resolve([]),
63
+ leg('text', () => textSearch(shelves, opts.query, SEARCH_POOL)),
64
+ ]);
65
+ const near = vectorHits.filter((h) => h.distance <= maxDistance);
66
+ const keyedText = textHits.map((h) => ({
67
+ shelfKey: `${h.library}/${h.shelf}`,
68
+ recordId: Number(h.id),
69
+ snippet: h.snippet,
70
+ }));
71
+ const fused = fuseRrf([near, keyedText], (h) => hitKey(h.shelfKey, h.recordId), opts.rrfK);
72
+ const snippets = new Map();
73
+ for (const h of keyedText)
74
+ snippets.set(hitKey(h.shelfKey, h.recordId), h.snippet);
75
+ for (const h of near)
76
+ snippets.set(hitKey(h.shelfKey, h.recordId), h.snippet);
77
+ const distances = new Map(near.map((h) => [hitKey(h.shelfKey, h.recordId), h.distance]));
78
+ return [...fused.entries()]
79
+ .map(([key, entry]) => {
80
+ const slash = key.lastIndexOf('/');
81
+ const shelfKey = key.slice(0, slash);
82
+ return {
83
+ shelfKey,
84
+ recordId: Number(key.slice(slash + 1)),
85
+ snippet: snippets.get(key) ?? '',
86
+ distance: distances.get(key) ?? null,
87
+ vectorRank: entry.ranks[0] ?? null,
88
+ textRank: entry.ranks[1] ?? null,
89
+ score: entry.score,
90
+ };
91
+ })
92
+ .sort((a, b) => b.score - a.score ||
93
+ hitKey(a.shelfKey, a.recordId).localeCompare(hitKey(b.shelfKey, b.recordId)))
94
+ .slice(0, opts.k);
95
+ }
@@ -0,0 +1,8 @@
1
+ import type { EntityManager } from '@mikro-orm/core';
2
+ export type SelectOpts = {
3
+ fields?: string[];
4
+ orderBy?: Record<string, 'ASC' | 'DESC' | 'asc' | 'desc'>;
5
+ limit?: number;
6
+ offset?: number;
7
+ };
8
+ export declare function selectRows(em: EntityManager, entityName: string, where?: Record<string, unknown>, opts?: SelectOpts): Promise<Record<string, unknown>[]>;
@@ -0,0 +1,11 @@
1
+ export async function selectRows(em, entityName, where = {}, opts = {}) {
2
+ const qb = em.createQueryBuilder(entityName);
3
+ qb.select(opts.fields ?? '*').where(where);
4
+ if (opts.orderBy)
5
+ qb.orderBy(opts.orderBy);
6
+ if (opts.limit !== undefined)
7
+ qb.limit(opts.limit, opts.offset);
8
+ else if (opts.offset !== undefined)
9
+ qb.offset(opts.offset);
10
+ return (await qb.execute('all', true));
11
+ }
@@ -1,6 +1,9 @@
1
1
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
2
  export declare class UnknownShelfError extends Error {
3
3
  }
4
+ export declare class SingleShelfError extends Error {
5
+ constructor(message: string);
6
+ }
4
7
  export type RecordListQuery = {
5
8
  q?: string;
6
9
  id?: string;
@@ -33,6 +36,7 @@ export declare function recordGet(library: string, shelf: string, id: number, op
33
36
  includeDeleted?: boolean;
34
37
  }): Promise<Record<string, unknown> | null>;
35
38
  export declare function recordCreate(library: string, shelf: string, body: unknown, actor?: string): Promise<Record<string, unknown>>;
39
+ export declare function ensureSingle(library: string, shelf: string): Promise<Record<string, unknown>>;
36
40
  export declare function recordUpdate(library: string, shelf: string, id: number, body: unknown, actor?: string): Promise<Record<string, unknown>>;
37
41
  export declare function recordDelete(library: string, shelf: string, id: number, actor?: string): Promise<void>;
38
42
  export declare function recordRestore(library: string, shelf: string, id: number, actor?: string): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { fieldMap, textSearchKeys, titleKey, recordTitle, listKeys, storageColumnsFor } from '@coffer-org/sdk/shelf';
2
2
  import { tokenize, matchScoreFolded, foldText } from '@coffer-org/core/search';
3
- import { serialize } from '@mikro-orm/core';
4
3
  import { getActiveRegistry, getShelf, getExtendsFor } from "./registry-context.js";
4
+ import { selectRows } from "./read-rows.js";
5
5
  import { shelfTableName } from "./entity-schema.js";
6
6
  import { getExtendRecord, getExtendRecords } from "./extend-table.js";
7
7
  import { getEm } from "./db.js";
@@ -11,11 +11,17 @@ import { createRecord, updateRecord, getRecord, deleteRecord, restoreRecord, pur
11
11
  import { deleteRecordActivity } from "./record-activity.js";
12
12
  export class UnknownShelfError extends Error {
13
13
  }
14
+ export class SingleShelfError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'SingleShelfError';
18
+ }
19
+ }
14
20
  export const MAX_PAGE = 500;
15
21
  function pickCols(row, cols) {
16
22
  const out = {};
17
23
  for (const c of cols)
18
- if (c in row)
24
+ if (c in row && row[c] !== null)
19
25
  out[c] = row[c];
20
26
  return out;
21
27
  }
@@ -112,7 +118,7 @@ export async function recordCount(library, shelf, query = {}, opts = {}) {
112
118
  return 0;
113
119
  return getEm().fork().count(ename, where);
114
120
  }
115
- export async function recordList(library, shelf, query = {}, opts = {}) {
121
+ async function listRecords(library, shelf, query = {}, opts = {}) {
116
122
  const { view = 'full', extends: withExt = true, deleted = 'active' } = opts;
117
123
  const { m, ename } = resolve(library, shelf);
118
124
  const { q, ...filterParams } = query;
@@ -137,7 +143,7 @@ export async function recordList(library, shelf, query = {}, opts = {}) {
137
143
  ...(opts.orderBy ? { orderBy: opts.orderBy } : {}),
138
144
  ...(sqlSlice ? { limit: opts.limit, offset: Math.max(0, opts.offset ?? 0) } : {}),
139
145
  };
140
- let rows = (await fork.find(ename, where, (Object.keys(findOpts).length ? findOpts : undefined))).map((r) => serialize(r));
146
+ let rows = await selectRows(fork, ename, where, findOpts);
141
147
  if (tokens.length > 0)
142
148
  rows = rows.filter((row) => rowMatch(m, row, tokens) !== null);
143
149
  if (projected)
@@ -147,6 +153,12 @@ export async function recordList(library, shelf, query = {}, opts = {}) {
147
153
  return decoded;
148
154
  return withExtendsMany(decoded, library, shelf);
149
155
  }
156
+ export async function recordList(library, shelf, query = {}, opts = {}) {
157
+ const { m } = resolve(library, shelf);
158
+ if (m.single)
159
+ await ensureSingle(library, shelf);
160
+ return listRecords(library, shelf, query, opts);
161
+ }
150
162
  export function isPagedRequest(limit, offset) {
151
163
  return [limit, offset].some((v) => v !== undefined && v.trim() !== '' && Number.isFinite(Number(v)));
152
164
  }
@@ -179,7 +191,7 @@ export async function recordGet(library, shelf, id, opts = {}) {
179
191
  return null;
180
192
  return withExtends(row, library, shelf);
181
193
  }
182
- export async function recordCreate(library, shelf, body, actor = 'gui') {
194
+ async function createOne(library, shelf, body, actor = 'gui') {
183
195
  const { m, ename } = resolve(library, shelf);
184
196
  const { base, extData } = splitBody(body);
185
197
  validateExtends(library, shelf, extData);
@@ -189,6 +201,37 @@ export async function recordCreate(library, shelf, body, actor = 'gui') {
189
201
  });
190
202
  return withExtends(row, library, shelf);
191
203
  }
204
+ export async function recordCreate(library, shelf, body, actor = 'gui') {
205
+ const { m } = resolve(library, shelf);
206
+ if (m.single) {
207
+ await ensureSingle(library, shelf);
208
+ throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record already exists — update it instead`);
209
+ }
210
+ return createOne(library, shelf, body, actor);
211
+ }
212
+ const singleInFlight = new Map();
213
+ export async function ensureSingle(library, shelf) {
214
+ const { m } = resolve(library, shelf);
215
+ if (!m.single)
216
+ throw new Error(`not a single shelf: ${library}/${shelf}`);
217
+ const key = `${library}/${shelf}`;
218
+ const pending = singleInFlight.get(key);
219
+ if (pending)
220
+ return pending;
221
+ const task = (async () => {
222
+ const rows = await listRecords(library, shelf, {}, { limit: 1, orderBy: { id: 'ASC' } });
223
+ if (rows[0])
224
+ return rows[0];
225
+ return createOne(library, shelf, {}, 'system');
226
+ })();
227
+ singleInFlight.set(key, task);
228
+ try {
229
+ return await task;
230
+ }
231
+ finally {
232
+ singleInFlight.delete(key);
233
+ }
234
+ }
192
235
  export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
193
236
  const { m, ename } = resolve(library, shelf);
194
237
  const { base, extData } = splitBody(body);
@@ -201,6 +244,8 @@ export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
201
244
  }
202
245
  export async function recordDelete(library, shelf, id, actor = 'gui') {
203
246
  const { m, ename } = resolve(library, shelf);
247
+ if (m.single)
248
+ throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record cannot be deleted — clear its fields instead`);
204
249
  await deleteRecord(m, ename, id, { actor });
205
250
  }
206
251
  export async function recordRestore(library, shelf, id, actor = 'gui') {
@@ -209,6 +254,8 @@ export async function recordRestore(library, shelf, id, actor = 'gui') {
209
254
  }
210
255
  export async function recordPurge(library, shelf, id, actor = 'gui') {
211
256
  const { m, ename } = resolve(library, shelf);
257
+ if (m.single)
258
+ throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record cannot be deleted — clear its fields instead`);
212
259
  await purgeRecord(m, ename, id, { actor }, (tx, recordId) => deleteExtends(tx, library, shelf, recordId));
213
260
  await deleteRecordActivity(library, shelf, id);
214
261
  }
@@ -216,7 +263,7 @@ export async function recordTrashList() {
216
263
  const shelves = getActiveRegistry().shelves.filter((s) => s.standalone !== false);
217
264
  const out = [];
218
265
  for (const m of shelves) {
219
- const rows = await recordList(m.library, m.shelf, {}, { view: 'full', extends: true, deleted: 'deleted' });
266
+ const rows = await listRecords(m.library, m.shelf, {}, { view: 'full', extends: true, deleted: 'deleted' });
220
267
  for (const record of rows)
221
268
  out.push({ library: m.library, shelf: m.shelf, label: m.label, record });
222
269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@coffer-org/core": "^2.1.4",
28
- "@coffer-org/sdk": "^2.1.3",
28
+ "@coffer-org/sdk": "^2.2.0",
29
29
  "@extractus/oembed-extractor": "^4.1.0",
30
30
  "@fastify/cors": "^11.2.0",
31
31
  "@fastify/multipart": "^10.0.0",