@coffer-org/server 1.13.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/counts.d.ts +6 -0
- package/dist/counts.js +31 -0
- package/dist/db.js +2 -0
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +6 -6
- package/dist/entity-schema.js +1 -1
- package/dist/file-fields.d.ts +6 -0
- package/dist/file-fields.js +121 -0
- package/dist/frontend-agent.d.ts +1 -0
- package/dist/frontend-agent.js +37 -0
- package/dist/global-search.d.ts +16 -0
- package/dist/global-search.js +87 -0
- package/dist/index-signal.d.ts +3 -2
- package/dist/index-signal.js +22 -8
- package/dist/index.js +78 -103
- package/dist/local-api.d.ts +5 -5
- package/dist/local-api.js +9 -9
- package/dist/mcp-http.js +16 -1
- package/dist/mcp-local.d.ts +5 -5
- package/dist/mcp-local.js +12 -12
- package/dist/mcp-tools.js +21 -10
- package/dist/migrations.d.ts +1 -0
- package/dist/migrations.js +72 -2
- package/dist/mutate.d.ts +1 -0
- package/dist/mutate.js +18 -2
- package/dist/plugin-hooks.d.ts +15 -1
- package/dist/plugin-runtime.d.ts +1 -0
- package/dist/plugin-runtime.js +24 -1
- package/dist/plugin-user-api.d.ts +4 -0
- package/dist/plugin-user-api.js +92 -0
- package/dist/public-url.d.ts +1 -0
- package/dist/public-url.js +4 -1
- package/dist/records-api.d.ts +16 -6
- package/dist/records-api.js +76 -32
- package/dist/search-index.d.ts +10 -0
- package/dist/search-index.js +100 -0
- package/dist/search-indexer.d.ts +4 -0
- package/dist/search-indexer.js +88 -0
- package/dist/thread-store.d.ts +10 -2
- package/dist/thread-store.js +40 -0
- package/package.json +3 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { textSearchKeys, titleKey } from '@coffer-org/sdk/shelf';
|
|
2
|
+
import { foldText } from '@coffer-org/core/search';
|
|
3
|
+
import { getLogger } from '@coffer-org/sdk/logger';
|
|
4
|
+
import { getEm } from "./db.js";
|
|
5
|
+
import { getDialect } from "./dialect.js";
|
|
6
|
+
import { flattenEmbedded } from "./collection-io.js";
|
|
7
|
+
import { encodeJson } from "./mutate.js";
|
|
8
|
+
const log = getLogger('search-index');
|
|
9
|
+
export const SEARCH_SEPARATOR = '\n';
|
|
10
|
+
export const MIN_FTS_QUERY_LENGTH = 3;
|
|
11
|
+
let ftsUnsupported = false;
|
|
12
|
+
export function ftsAvailable() {
|
|
13
|
+
if (ftsUnsupported)
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
return getDialect() === 'sqlite';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function foldedTextFor(m, record) {
|
|
23
|
+
const row = encodeJson(m, flattenEmbedded(m, record));
|
|
24
|
+
const keys = [...new Set([...textSearchKeys(m), titleKey(m)])].filter(Boolean);
|
|
25
|
+
return keys
|
|
26
|
+
.map((k) => String(row[k] ?? ''))
|
|
27
|
+
.filter((s) => s.length > 0)
|
|
28
|
+
.map(foldText)
|
|
29
|
+
.join(SEARCH_SEPARATOR);
|
|
30
|
+
}
|
|
31
|
+
export function buildFtsMatchQuery(tokens) {
|
|
32
|
+
return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' AND ');
|
|
33
|
+
}
|
|
34
|
+
export async function ensureSearchTable() {
|
|
35
|
+
if (!ftsAvailable())
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
await getEm()
|
|
39
|
+
.fork()
|
|
40
|
+
.getConnection()
|
|
41
|
+
.execute(`CREATE VIRTUAL TABLE IF NOT EXISTS "_search" USING fts5(
|
|
42
|
+
shelf UNINDEXED,
|
|
43
|
+
record_id UNINDEXED,
|
|
44
|
+
folded,
|
|
45
|
+
tokenize='trigram'
|
|
46
|
+
)`);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
ftsUnsupported = true;
|
|
50
|
+
log.warn(`FTS5 index unavailable, global search falls back to the scan — ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function upsertSearchRow(shelf, recordId, folded) {
|
|
54
|
+
if (!ftsAvailable())
|
|
55
|
+
return;
|
|
56
|
+
const conn = getEm().fork().getConnection();
|
|
57
|
+
await conn.execute('DELETE FROM "_search" WHERE shelf = ? AND record_id = ?', [shelf, recordId]);
|
|
58
|
+
if (!folded)
|
|
59
|
+
return;
|
|
60
|
+
await conn.execute('INSERT INTO "_search" (shelf, record_id, folded) VALUES (?, ?, ?)', [
|
|
61
|
+
shelf,
|
|
62
|
+
recordId,
|
|
63
|
+
folded,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
export async function deleteSearchRow(shelf, recordId) {
|
|
67
|
+
if (!ftsAvailable())
|
|
68
|
+
return;
|
|
69
|
+
await getEm()
|
|
70
|
+
.fork()
|
|
71
|
+
.getConnection()
|
|
72
|
+
.execute('DELETE FROM "_search" WHERE shelf = ? AND record_id = ?', [shelf, recordId]);
|
|
73
|
+
}
|
|
74
|
+
export async function ftsCandidates(tokens) {
|
|
75
|
+
const out = new Map();
|
|
76
|
+
if (!ftsAvailable() || tokens.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
if (tokens.some((t) => t.length < MIN_FTS_QUERY_LENGTH))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const rows = (await getEm()
|
|
82
|
+
.fork()
|
|
83
|
+
.getConnection()
|
|
84
|
+
.execute('SELECT shelf, record_id FROM "_search" WHERE "_search" MATCH ?', [
|
|
85
|
+
buildFtsMatchQuery(tokens),
|
|
86
|
+
]));
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
const list = out.get(r.shelf);
|
|
89
|
+
if (list)
|
|
90
|
+
list.push(Number(r.record_id));
|
|
91
|
+
else
|
|
92
|
+
out.set(r.shelf, [Number(r.record_id)]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
log.warn(`MATCH failed — ${e.message}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getLogger } from '@coffer-org/sdk/logger';
|
|
2
|
+
import { listEventsSince } from "./embeddings.js";
|
|
3
|
+
import { getPluginState, setPluginState } from "./plugin-state.js";
|
|
4
|
+
import { getShelf } from "./registry-context.js";
|
|
5
|
+
import { onRecordsChanged } from "./index-signal.js";
|
|
6
|
+
import { ftsAvailable, foldedTextFor, upsertSearchRow, deleteSearchRow } from "./search-index.js";
|
|
7
|
+
const log = getLogger('search-indexer');
|
|
8
|
+
export const SEARCH_STATE_PLUGIN = 'core';
|
|
9
|
+
export const SEARCH_CURSOR_KEY = 'fts_last_event_id';
|
|
10
|
+
const DEFAULT_BATCH = 5000;
|
|
11
|
+
export async function indexSearchOnce(batch = DEFAULT_BATCH) {
|
|
12
|
+
if (!ftsAvailable())
|
|
13
|
+
return 0;
|
|
14
|
+
const last = Number((await getPluginState(SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY)) ?? '0');
|
|
15
|
+
const rows = await listEventsSince(last, batch);
|
|
16
|
+
if (rows.length === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
const byRef = new Map();
|
|
19
|
+
for (const r of rows) {
|
|
20
|
+
if (r.record_id == null)
|
|
21
|
+
continue;
|
|
22
|
+
const [library, shelf] = r.type.split('/');
|
|
23
|
+
if (!library || !shelf)
|
|
24
|
+
continue;
|
|
25
|
+
const key = `${r.type}/${r.record_id}`;
|
|
26
|
+
if (r.op === 'delete') {
|
|
27
|
+
byRef.set(key, { shelf: r.type, recordId: r.record_id, op: 'delete', folded: '' });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!r.after)
|
|
31
|
+
continue;
|
|
32
|
+
const m = getShelf(library, shelf);
|
|
33
|
+
if (!m) {
|
|
34
|
+
byRef.delete(key);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let after;
|
|
38
|
+
try {
|
|
39
|
+
after = JSON.parse(r.after);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
log.warn(`event ${r.id} (${r.type}): malformed after-snapshot, skipping — ${e.message}`);
|
|
43
|
+
byRef.delete(key);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
byRef.set(key, { shelf: r.type, recordId: r.record_id, op: 'upsert', folded: foldedTextFor(m, after) });
|
|
47
|
+
}
|
|
48
|
+
for (const p of byRef.values()) {
|
|
49
|
+
if (p.op === 'delete')
|
|
50
|
+
await deleteSearchRow(p.shelf, p.recordId);
|
|
51
|
+
else
|
|
52
|
+
await upsertSearchRow(p.shelf, p.recordId, p.folded);
|
|
53
|
+
}
|
|
54
|
+
const lastId = rows[rows.length - 1].id;
|
|
55
|
+
await setPluginState(SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY, String(lastId));
|
|
56
|
+
log.debug(`indexed ${byRef.size} record(s) from ${rows.length} event(s), cursor → ${lastId}`);
|
|
57
|
+
return rows.length;
|
|
58
|
+
}
|
|
59
|
+
export function startSearchIndexer(batch = DEFAULT_BATCH) {
|
|
60
|
+
if (!ftsAvailable())
|
|
61
|
+
return () => { };
|
|
62
|
+
let running = false;
|
|
63
|
+
let again = false;
|
|
64
|
+
const run = async () => {
|
|
65
|
+
if (running) {
|
|
66
|
+
again = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
running = true;
|
|
70
|
+
try {
|
|
71
|
+
do {
|
|
72
|
+
again = false;
|
|
73
|
+
let processed;
|
|
74
|
+
do {
|
|
75
|
+
processed = await indexSearchOnce(batch);
|
|
76
|
+
} while (processed === batch);
|
|
77
|
+
} while (again);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
log.warn(`pass failed — ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
running = false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
void run();
|
|
87
|
+
return onRecordsChanged(() => void run());
|
|
88
|
+
}
|
package/dist/thread-store.d.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
export interface StoredMsg {
|
|
2
2
|
msgId: string;
|
|
3
|
-
role: 'user' | 'assistant';
|
|
3
|
+
role: 'user' | 'assistant' | 'reasoning';
|
|
4
4
|
sender: string | null;
|
|
5
5
|
text: string;
|
|
6
6
|
ts: number;
|
|
7
7
|
replyToId: string | null;
|
|
8
8
|
}
|
|
9
|
+
export interface ThreadChat {
|
|
10
|
+
chatId: string;
|
|
11
|
+
lastTs: number;
|
|
12
|
+
count: number;
|
|
13
|
+
firstUserText: string;
|
|
14
|
+
}
|
|
9
15
|
export declare function getThreadMessage(connector: string, chatId: string, msgId: string): Promise<StoredMsg | null>;
|
|
10
16
|
export declare function putThreadMessage(m: {
|
|
11
17
|
connector: string;
|
|
12
18
|
chatId: string;
|
|
13
19
|
msgId: string;
|
|
14
|
-
role: 'user' | 'assistant';
|
|
20
|
+
role: 'user' | 'assistant' | 'reasoning';
|
|
15
21
|
sender?: string | null;
|
|
16
22
|
text: string;
|
|
17
23
|
ts: number;
|
|
18
24
|
replyToId: string | null;
|
|
19
25
|
}): Promise<void>;
|
|
20
26
|
export declare function pruneThreadMessages(connector: string, cutoffTs: number): Promise<void>;
|
|
27
|
+
export declare function listThreadMessages(connector: string, chatId: string, limit?: number): Promise<StoredMsg[]>;
|
|
28
|
+
export declare function listThreadChats(connector: string, chatIdPrefix: string): Promise<ThreadChat[]>;
|
package/dist/thread-store.js
CHANGED
|
@@ -25,3 +25,43 @@ export async function pruneThreadMessages(connector, cutoffTs) {
|
|
|
25
25
|
const em = getEm().fork();
|
|
26
26
|
await em.nativeDelete('_ThreadMessage', { connector, ts: { $lt: cutoffTs } });
|
|
27
27
|
}
|
|
28
|
+
export async function listThreadMessages(connector, chatId, limit = 200) {
|
|
29
|
+
const em = getEm().fork();
|
|
30
|
+
const visible = (await em.find('_ThreadMessage', { connector, chat_id: chatId, role: { $ne: 'reasoning' } }, { orderBy: { ts: 'desc', msg_id: 'desc' }, limit }));
|
|
31
|
+
const oldest = visible.at(-1)?.ts;
|
|
32
|
+
const reasoning = oldest === undefined ? [] : (await em.find('_ThreadMessage', { connector, chat_id: chatId, role: 'reasoning', ts: { $gte: oldest - 1 } }, { orderBy: { ts: 'desc', msg_id: 'desc' } }));
|
|
33
|
+
return [...visible, ...reasoning]
|
|
34
|
+
.sort((a, b) => b.ts - a.ts || (a.msg_id < b.msg_id ? 1 : a.msg_id > b.msg_id ? -1 : 0))
|
|
35
|
+
.reverse()
|
|
36
|
+
.map(toStored);
|
|
37
|
+
}
|
|
38
|
+
export async function listThreadChats(connector, chatIdPrefix) {
|
|
39
|
+
const em = getEm().fork();
|
|
40
|
+
if (!chatIdPrefix) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const last = chatIdPrefix.charCodeAt(chatIdPrefix.length - 1);
|
|
44
|
+
const upper = chatIdPrefix.slice(0, -1) + String.fromCharCode(last + 1);
|
|
45
|
+
const rows = (await em.find('_ThreadMessage', { connector, chat_id: { $gte: chatIdPrefix, $lt: upper } }, { orderBy: { ts: 'asc' } }));
|
|
46
|
+
const byChat = new Map();
|
|
47
|
+
for (const r of rows) {
|
|
48
|
+
if (r.role === 'reasoning')
|
|
49
|
+
continue;
|
|
50
|
+
const cur = byChat.get(r.chat_id);
|
|
51
|
+
if (!cur) {
|
|
52
|
+
byChat.set(r.chat_id, {
|
|
53
|
+
chatId: r.chat_id,
|
|
54
|
+
lastTs: r.ts,
|
|
55
|
+
count: 1,
|
|
56
|
+
firstUserText: r.role === 'user' ? r.text : '',
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
cur.count += 1;
|
|
61
|
+
if (r.ts > cur.lastTs)
|
|
62
|
+
cur.lastTs = r.ts;
|
|
63
|
+
if (!cur.firstUserText && r.role === 'user')
|
|
64
|
+
cur.firstUserText = r.text;
|
|
65
|
+
}
|
|
66
|
+
return [...byChat.values()].sort((a, b) => b.lastTs - a.lastTs);
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffer-org/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"postpack": "node ../../scripts/swap-exports.mjs src"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@coffer-org/core": "^
|
|
28
|
-
"@coffer-org/sdk": "^
|
|
27
|
+
"@coffer-org/core": "^2.0.0",
|
|
28
|
+
"@coffer-org/sdk": "^2.0.0",
|
|
29
29
|
"@extractus/oembed-extractor": "^4.1.0",
|
|
30
30
|
"@fastify/cors": "^11.2.0",
|
|
31
31
|
"@fastify/multipart": "^10.0.0",
|