@coffer-org/server 2.4.0 → 2.5.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.
@@ -16,6 +16,7 @@ export declare const MigrationRowSchema: EntitySchema<any, never, import("@mikro
16
16
  export declare const SeedRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
17
17
  export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
18
18
  export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
+ export declare const RecordActivitySchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
20
  export declare const ThreadMessageSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
20
21
  export declare function buildPluginEntities(plugins: PluginManifest[]): EntitySchema[];
21
22
  export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
@@ -152,6 +152,19 @@ export const PluginStateSchema = new EntitySchema({
152
152
  value: { type: 'text' },
153
153
  },
154
154
  });
155
+ export const RecordActivitySchema = new EntitySchema({
156
+ name: '_RecordActivity',
157
+ tableName: '_record_activity',
158
+ properties: {
159
+ user_id: { type: 'integer', primary: true },
160
+ library: { type: 'text', primary: true },
161
+ shelf: { type: 'text', primary: true },
162
+ record_id: { type: 'integer', primary: true },
163
+ favorite: { type: 'integer' },
164
+ view_count: { type: 'integer' },
165
+ last_viewed_at: { type: 'text', nullable: true },
166
+ },
167
+ });
155
168
  export const ThreadMessageSchema = new EntitySchema({
156
169
  name: '_ThreadMessage',
157
170
  tableName: '_thread_message',
@@ -268,7 +281,7 @@ export const OAuthTokenSchema = new EntitySchema({
268
281
  });
269
282
  export const systemEntities = [
270
283
  EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
271
- EmbeddingSchema, PluginStateSchema, MsgLogSchema,
284
+ EmbeddingSchema, PluginStateSchema, RecordActivitySchema, MsgLogSchema,
272
285
  UserSchema, SessionSchema, ApiTokenSchema,
273
286
  OAuthClientSchema, OAuthCodeSchema, OAuthTokenSchema,
274
287
  ThreadMessageSchema,
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, UnknownShelfError, } from "./records-api.js";
27
+ import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, } 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";
@@ -34,6 +34,8 @@ import { verifyUploadTicket } from "./upload-ticket.js";
34
34
  import { setPluginState } from "./plugin-state.js";
35
35
  import { SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY } from "./search-indexer.js";
36
36
  import { globalSearch } from "./global-search.js";
37
+ import { applySmartRanking, getRecordActivity, markRecordViewed, setRecordFavorite } from "./record-activity.js";
38
+ import { tracksRecordActivity } from "./record-activity-policy.js";
37
39
  const ENV_FILE = join(process.cwd(), '.env');
38
40
  if (existsSync(ENV_FILE))
39
41
  process.loadEnvFile(ENV_FILE);
@@ -410,14 +412,25 @@ app.get('/api/:library/:shelf', async (req, reply) => {
410
412
  };
411
413
  try {
412
414
  if (!paged) {
413
- const rows = await recordList(library, shelf, query, opts);
415
+ let rows = await recordList(library, shelf, query, opts);
416
+ if (req.user && tracksRecordActivity(req.headers))
417
+ rows = await applySmartRanking(req.user.id, library, shelf, rows);
414
418
  return rows.map((r) => maskRecordRow(library, shelf, r));
415
419
  }
416
- const page = await recordListPage(library, shelf, query, {
420
+ const pageOpts = {
417
421
  ...opts,
418
422
  limit: limit === undefined ? undefined : Number(limit),
419
423
  offset: offset === undefined ? undefined : Number(offset),
420
- });
424
+ };
425
+ let page = await recordListPage(library, shelf, query, pageOpts);
426
+ if (req.user && tracksRecordActivity(req.headers)) {
427
+ const allRows = await recordList(library, shelf, query, { ...opts, limit: undefined, offset: undefined });
428
+ const ranked = await applySmartRanking(req.user.id, library, shelf, allRows);
429
+ const rawLimit = Number.isFinite(pageOpts.limit) ? Math.trunc(pageOpts.limit) : MAX_PAGE;
430
+ const pageLimit = Math.min(Math.max(1, rawLimit), MAX_PAGE);
431
+ const pageOffset = Math.max(0, Number.isFinite(pageOpts.offset) ? Math.trunc(pageOpts.offset) : 0);
432
+ page = { rows: ranked.slice(pageOffset, pageOffset + pageLimit), total: ranked.length };
433
+ }
421
434
  return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
422
435
  }
423
436
  catch (e) {
@@ -426,6 +439,43 @@ app.get('/api/:library/:shelf', async (req, reply) => {
426
439
  throw e;
427
440
  }
428
441
  });
442
+ app.get('/api/:library/:shelf/:id/activity', async (req, reply) => {
443
+ const { library, shelf, id } = req.params;
444
+ const rid = Number(id);
445
+ if (isNaN(rid))
446
+ return reply.code(400).send({ error: 'invalid_id' });
447
+ try {
448
+ const row = await recordGet(library, shelf, rid);
449
+ if (!row)
450
+ return reply.code(404).send({ error: 'not_found' });
451
+ return { activity: await getRecordActivity(req.user.id, library, shelf, rid) };
452
+ }
453
+ catch (e) {
454
+ if (e instanceof UnknownShelfError)
455
+ return reply.code(404).send({ error: 'unknown_shelf' });
456
+ throw e;
457
+ }
458
+ });
459
+ app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
460
+ const { library, shelf, id } = req.params;
461
+ const rid = Number(id);
462
+ if (isNaN(rid))
463
+ return reply.code(400).send({ error: 'invalid_id' });
464
+ const body = (req.body ?? {});
465
+ if (typeof body.favorite !== 'boolean')
466
+ return reply.code(400).send({ error: 'favorite_must_be_boolean' });
467
+ try {
468
+ const row = await recordGet(library, shelf, rid);
469
+ if (!row)
470
+ return reply.code(404).send({ error: 'not_found' });
471
+ return { activity: await setRecordFavorite(req.user.id, library, shelf, rid, body.favorite) };
472
+ }
473
+ catch (e) {
474
+ if (e instanceof UnknownShelfError)
475
+ return reply.code(404).send({ error: 'unknown_shelf' });
476
+ throw e;
477
+ }
478
+ });
429
479
  app.get('/api/:library/:shelf/:id', async (req, reply) => {
430
480
  const { library, shelf, id } = req.params;
431
481
  const rid = Number(id);
@@ -435,7 +485,11 @@ app.get('/api/:library/:shelf/:id', async (req, reply) => {
435
485
  const row = await recordGet(library, shelf, rid);
436
486
  if (!row)
437
487
  return reply.code(404).send({ error: 'not_found' });
438
- return maskRecordRow(library, shelf, row);
488
+ const masked = maskRecordRow(library, shelf, row);
489
+ if (req.user && tracksRecordActivity(req.headers)) {
490
+ return { ...masked, _activity: await markRecordViewed(req.user.id, library, shelf, rid) };
491
+ }
492
+ return masked;
439
493
  }
440
494
  catch (e) {
441
495
  if (e instanceof UnknownShelfError)
package/dist/msg-log.d.ts CHANGED
@@ -8,4 +8,14 @@ export interface MsgLogRow {
8
8
  tokensOut: number | null;
9
9
  ms: number | null;
10
10
  }
11
+ export interface MsgLogDiagnosticRow {
12
+ id: number;
13
+ ts: string;
14
+ connector: string;
15
+ role: 'user' | 'assistant';
16
+ tokensIn: number | null;
17
+ tokensOut: number | null;
18
+ ms: number | null;
19
+ }
11
20
  export declare function logMessage(row: MsgLogRow): Promise<void>;
21
+ export declare function listRecentMessageMetrics(limit?: number): Promise<MsgLogDiagnosticRow[]>;
package/dist/msg-log.js CHANGED
@@ -3,3 +3,19 @@ export async function logMessage(row) {
3
3
  await getEm().fork().getConnection().execute(`INSERT INTO _orch_msg_log (ts, connector, chat_id, user_id, role, text, tokens_in, tokens_out, ms)
4
4
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [new Date().toISOString(), row.connector, row.chatId, row.userId, row.role, row.text, row.tokensIn, row.tokensOut, row.ms]);
5
5
  }
6
+ export async function listRecentMessageMetrics(limit = 25) {
7
+ const bounded = Math.max(1, Math.min(100, Math.trunc(limit)));
8
+ const rows = (await getEm().fork().getConnection().execute(`SELECT id, ts, connector, role, tokens_in, tokens_out, ms
9
+ FROM _orch_msg_log
10
+ ORDER BY id DESC
11
+ LIMIT ?`, [bounded]));
12
+ return rows.map((r) => ({
13
+ id: Number(r['id']),
14
+ ts: String(r['ts']),
15
+ connector: String(r['connector']),
16
+ role: r['role'] === 'assistant' ? 'assistant' : 'user',
17
+ tokensIn: r['tokens_in'] == null ? null : Number(r['tokens_in']),
18
+ tokensOut: r['tokens_out'] == null ? null : Number(r['tokens_out']),
19
+ ms: r['ms'] == null ? null : Number(r['ms']),
20
+ }));
21
+ }
@@ -0,0 +1,3 @@
1
+ export declare const AUTOMATED_ACTIVITY_HEADER = "x-coffer-automated";
2
+ export type RequestHeaders = Record<string, string | string[] | undefined>;
3
+ export declare function tracksRecordActivity(headers: RequestHeaders): boolean;
@@ -0,0 +1,7 @@
1
+ export const AUTOMATED_ACTIVITY_HEADER = 'x-coffer-automated';
2
+ export function tracksRecordActivity(headers) {
3
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === AUTOMATED_ACTIVITY_HEADER);
4
+ const marker = entry?.[1];
5
+ const value = Array.isArray(marker) ? marker[0] : marker;
6
+ return value?.toLowerCase().trim() !== 'true';
7
+ }
@@ -0,0 +1,11 @@
1
+ export interface RecordActivityClient {
2
+ favorite: boolean;
3
+ viewCount: number;
4
+ lastViewedAt: string | null;
5
+ score: number;
6
+ }
7
+ export declare function getRecordActivity(userId: number, library: string, shelf: string, recordId: number): Promise<RecordActivityClient>;
8
+ export declare function markRecordViewed(userId: number, library: string, shelf: string, recordId: number, now?: string): Promise<RecordActivityClient>;
9
+ export declare function setRecordFavorite(userId: number, library: string, shelf: string, recordId: number, favorite: boolean): Promise<RecordActivityClient>;
10
+ export declare function deleteRecordActivity(library: string, shelf: string, recordId: number): Promise<void>;
11
+ export declare function applySmartRanking(userId: number, library: string, shelf: string, rows: Record<string, unknown>[], now?: Date): Promise<Record<string, unknown>[]>;
@@ -0,0 +1,118 @@
1
+ import { getEm } from "./db.js";
2
+ import { shelfTableName } from "./entity-schema.js";
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ const CREATED_HALF_LIFE_DAYS = 30;
5
+ const EDITED_HALF_LIFE_DAYS = 14;
6
+ function activityKey(userId, library, shelf, recordId) {
7
+ return { user_id: userId, library, shelf, record_id: recordId };
8
+ }
9
+ function toClient(row) {
10
+ return {
11
+ favorite: row?.favorite === 1,
12
+ viewCount: Number(row?.view_count ?? 0),
13
+ lastViewedAt: row?.last_viewed_at ?? null,
14
+ score: 0,
15
+ };
16
+ }
17
+ async function getActivityRow(userId, library, shelf, recordId) {
18
+ const em = getEm().fork();
19
+ return (await em.findOne('_RecordActivity', activityKey(userId, library, shelf, recordId)));
20
+ }
21
+ export async function getRecordActivity(userId, library, shelf, recordId) {
22
+ return toClient((await getActivityRow(userId, library, shelf, recordId)) ?? undefined);
23
+ }
24
+ export async function markRecordViewed(userId, library, shelf, recordId, now = new Date().toISOString()) {
25
+ const em = getEm().fork();
26
+ const key = activityKey(userId, library, shelf, recordId);
27
+ const row = (await em.findOne('_RecordActivity', key));
28
+ if (row) {
29
+ row.view_count = Number(row.view_count ?? 0) + 1;
30
+ row.last_viewed_at = now;
31
+ await em.flush();
32
+ return toClient(row);
33
+ }
34
+ const created = em.create('_RecordActivity', {
35
+ ...key,
36
+ favorite: 0,
37
+ view_count: 1,
38
+ last_viewed_at: now,
39
+ });
40
+ await em.flush();
41
+ return toClient(created);
42
+ }
43
+ export async function setRecordFavorite(userId, library, shelf, recordId, favorite) {
44
+ const em = getEm().fork();
45
+ const key = activityKey(userId, library, shelf, recordId);
46
+ const row = (await em.findOne('_RecordActivity', key));
47
+ if (row) {
48
+ row.favorite = favorite ? 1 : 0;
49
+ await em.flush();
50
+ return toClient(row);
51
+ }
52
+ const created = em.create('_RecordActivity', {
53
+ ...key,
54
+ favorite: favorite ? 1 : 0,
55
+ view_count: 0,
56
+ last_viewed_at: null,
57
+ });
58
+ await em.flush();
59
+ return toClient(created);
60
+ }
61
+ export async function deleteRecordActivity(library, shelf, recordId) {
62
+ const em = getEm().fork();
63
+ await em.nativeDelete('_RecordActivity', { library, shelf, record_id: recordId });
64
+ }
65
+ async function listActivityRows(userId, library, shelf, ids) {
66
+ if (ids.length === 0)
67
+ return new Map();
68
+ const em = getEm().fork();
69
+ const rows = (await em.find('_RecordActivity', { user_id: userId, library, shelf, record_id: { $in: ids } }));
70
+ return new Map(rows.map((row) => [Number(row.record_id), row]));
71
+ }
72
+ function decayBonus(base, timestamp, halfLifeDays, nowMs) {
73
+ const then = timestamp ? Date.parse(timestamp) : NaN;
74
+ if (!Number.isFinite(then))
75
+ return 0;
76
+ const ageDays = Math.max(0, nowMs - then) / DAY_MS;
77
+ return base * 2 ** (-ageDays / halfLifeDays);
78
+ }
79
+ export async function applySmartRanking(userId, library, shelf, rows, now = new Date()) {
80
+ if (rows.length === 0)
81
+ return rows;
82
+ const ids = rows.map((row) => Number(row.id)).filter((id) => Number.isInteger(id));
83
+ const activities = await listActivityRows(userId, library, shelf, ids);
84
+ const em = getEm().fork();
85
+ const timestamps = (await em.find(shelfTableName(library, shelf), { id: { $in: ids }, deleted_at: null }, { fields: ['id', 'created_at', 'updated_at'] }));
86
+ const dates = new Map(timestamps.map((row) => [Number(row.id), row]));
87
+ const viewed = rows
88
+ .map((row) => ({ id: Number(row.id), last: activities.get(Number(row.id))?.last_viewed_at }))
89
+ .filter((row) => typeof row.last === 'string' && Number.isFinite(Date.parse(row.last)))
90
+ .sort((a, b) => Date.parse(b.last) - Date.parse(a.last));
91
+ const recentReadIds = new Set(viewed.slice(0, Math.max(1, Math.ceil(rows.length * 0.1))).map((row) => row.id));
92
+ const nowMs = now.getTime();
93
+ const decorated = rows.map((row) => {
94
+ const id = Number(row.id);
95
+ const activity = activities.get(id);
96
+ const date = dates.get(id);
97
+ const created = date?.created_at;
98
+ const updated = date?.updated_at;
99
+ const createdBonus = decayBonus(75, created, CREATED_HALF_LIFE_DAYS, nowMs);
100
+ const edited = created && updated && Date.parse(updated) > Date.parse(created) ? updated : null;
101
+ const editedBonus = decayBonus(50, edited, EDITED_HALF_LIFE_DAYS, nowMs);
102
+ const score = (activity?.favorite === 1 ? 100 : 0) + createdBonus + editedBonus + (recentReadIds.has(id) ? 30 : 0);
103
+ const client = {
104
+ favorite: activity?.favorite === 1,
105
+ viewCount: Number(activity?.view_count ?? 0),
106
+ lastViewedAt: activity?.last_viewed_at ?? null,
107
+ score,
108
+ };
109
+ return { row: { ...row, _activity: client }, client, updated: updated ?? '' };
110
+ });
111
+ return decorated
112
+ .sort((a, b) => b.client.score - a.client.score ||
113
+ b.client.viewCount - a.client.viewCount ||
114
+ String(b.client.lastViewedAt ?? '').localeCompare(String(a.client.lastViewedAt ?? '')) ||
115
+ b.updated.localeCompare(a.updated) ||
116
+ Number(b.row.id) - Number(a.row.id))
117
+ .map((item) => item.row);
118
+ }
@@ -8,6 +8,7 @@ import { getEm } from "./db.js";
8
8
  import { decodeTemporal, dtStringToDate } from "./temporal.js";
9
9
  import { splitBody, saveExtends, deleteExtends, validateExtends, readExtends } from "./extend-io.js";
10
10
  import { createRecord, updateRecord, getRecord, deleteRecord, restoreRecord, purgeRecord } from "./mutate.js";
11
+ import { deleteRecordActivity } from "./record-activity.js";
11
12
  export class UnknownShelfError extends Error {
12
13
  }
13
14
  export const MAX_PAGE = 500;
@@ -209,6 +210,7 @@ export async function recordRestore(library, shelf, id, actor = 'gui') {
209
210
  export async function recordPurge(library, shelf, id, actor = 'gui') {
210
211
  const { m, ename } = resolve(library, shelf);
211
212
  await purgeRecord(m, ename, id, { actor }, (tx, recordId) => deleteExtends(tx, library, shelf, recordId));
213
+ await deleteRecordActivity(library, shelf, id);
212
214
  }
213
215
  export async function recordTrashList() {
214
216
  const shelves = getActiveRegistry().shelves.filter((s) => s.standalone !== false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"