@coffer-org/server 2.4.0 → 2.5.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.
@@ -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
@@ -34,6 +34,7 @@ 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";
37
38
  const ENV_FILE = join(process.cwd(), '.env');
38
39
  if (existsSync(ENV_FILE))
39
40
  process.loadEnvFile(ENV_FILE);
@@ -363,6 +364,11 @@ function maskRecordRow(library, shelf, row) {
363
364
  }
364
365
  return out;
365
366
  }
367
+ function tracksRecordActivity(req) {
368
+ const marker = req.headers['x-coffer-automated'];
369
+ const value = Array.isArray(marker) ? marker[0] : marker;
370
+ return value?.toLowerCase().trim() !== 'true';
371
+ }
366
372
  app.get('/api/trash', async (_req, reply) => {
367
373
  const rows = await recordTrashList();
368
374
  return rows.map((entry) => ({ ...entry, record: maskRecordRow(entry.library, entry.shelf, entry.record) }));
@@ -410,14 +416,19 @@ app.get('/api/:library/:shelf', async (req, reply) => {
410
416
  };
411
417
  try {
412
418
  if (!paged) {
413
- const rows = await recordList(library, shelf, query, opts);
419
+ let rows = await recordList(library, shelf, query, opts);
420
+ if (req.user && tracksRecordActivity(req))
421
+ rows = await applySmartRanking(req.user.id, library, shelf, rows);
414
422
  return rows.map((r) => maskRecordRow(library, shelf, r));
415
423
  }
416
- const page = await recordListPage(library, shelf, query, {
424
+ let page = await recordListPage(library, shelf, query, {
417
425
  ...opts,
418
426
  limit: limit === undefined ? undefined : Number(limit),
419
427
  offset: offset === undefined ? undefined : Number(offset),
420
428
  });
429
+ if (req.user && tracksRecordActivity(req)) {
430
+ page = { ...page, rows: await applySmartRanking(req.user.id, library, shelf, page.rows) };
431
+ }
421
432
  return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
422
433
  }
423
434
  catch (e) {
@@ -426,6 +437,43 @@ app.get('/api/:library/:shelf', async (req, reply) => {
426
437
  throw e;
427
438
  }
428
439
  });
440
+ app.get('/api/:library/:shelf/:id/activity', async (req, reply) => {
441
+ const { library, shelf, id } = req.params;
442
+ const rid = Number(id);
443
+ if (isNaN(rid))
444
+ return reply.code(400).send({ error: 'invalid_id' });
445
+ try {
446
+ const row = await recordGet(library, shelf, rid);
447
+ if (!row)
448
+ return reply.code(404).send({ error: 'not_found' });
449
+ return { activity: await getRecordActivity(req.user.id, library, shelf, rid) };
450
+ }
451
+ catch (e) {
452
+ if (e instanceof UnknownShelfError)
453
+ return reply.code(404).send({ error: 'unknown_shelf' });
454
+ throw e;
455
+ }
456
+ });
457
+ app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
458
+ const { library, shelf, id } = req.params;
459
+ const rid = Number(id);
460
+ if (isNaN(rid))
461
+ return reply.code(400).send({ error: 'invalid_id' });
462
+ const body = (req.body ?? {});
463
+ if (typeof body.favorite !== 'boolean')
464
+ return reply.code(400).send({ error: 'favorite_must_be_boolean' });
465
+ try {
466
+ const row = await recordGet(library, shelf, rid);
467
+ if (!row)
468
+ return reply.code(404).send({ error: 'not_found' });
469
+ return { activity: await setRecordFavorite(req.user.id, library, shelf, rid, body.favorite) };
470
+ }
471
+ catch (e) {
472
+ if (e instanceof UnknownShelfError)
473
+ return reply.code(404).send({ error: 'unknown_shelf' });
474
+ throw e;
475
+ }
476
+ });
429
477
  app.get('/api/:library/:shelf/:id', async (req, reply) => {
430
478
  const { library, shelf, id } = req.params;
431
479
  const rid = Number(id);
@@ -435,7 +483,11 @@ app.get('/api/:library/:shelf/:id', async (req, reply) => {
435
483
  const row = await recordGet(library, shelf, rid);
436
484
  if (!row)
437
485
  return reply.code(404).send({ error: 'not_found' });
438
- return maskRecordRow(library, shelf, row);
486
+ const masked = maskRecordRow(library, shelf, row);
487
+ if (req.user && tracksRecordActivity(req)) {
488
+ return { ...masked, _activity: await markRecordViewed(req.user.id, library, shelf, rid) };
489
+ }
490
+ return masked;
439
491
  }
440
492
  catch (e) {
441
493
  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,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.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"