@shadowforge0/aquifer-memory 0.6.0 → 0.8.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/README.md +62 -9
- package/consumers/cli.js +11 -78
- package/consumers/mcp.js +68 -4
- package/consumers/openclaw-plugin.js +18 -5
- package/consumers/shared/config.js +21 -1
- package/consumers/shared/factory.js +26 -5
- package/core/aquifer.js +237 -24
- package/core/storage.js +60 -16
- package/index.js +3 -1
- package/package.json +3 -3
- package/pipeline/_http.js +67 -0
- package/pipeline/embed.js +1 -63
- package/pipeline/normalize/adapters/claude-code.js +90 -0
- package/pipeline/normalize/adapters/gateway.js +67 -0
- package/pipeline/normalize/constants.js +12 -0
- package/pipeline/normalize/detect.js +52 -0
- package/pipeline/normalize/extract.js +49 -0
- package/pipeline/normalize/index.js +129 -0
- package/pipeline/normalize/timestamp.js +33 -0
- package/pipeline/rerank.js +161 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code adapter — for Claude Code CLI sessions.
|
|
5
|
+
* Entry types are 'user'/'assistant' (split format: one content type per entry).
|
|
6
|
+
* Text and tool_use are separate entries, enabling narration detection via look-ahead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { extractContent } = require('../extract');
|
|
10
|
+
const { parseTimestamp } = require('../timestamp');
|
|
11
|
+
const { MAX_NARRATION_CHARS } = require('../constants');
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
name: 'claude-code',
|
|
15
|
+
|
|
16
|
+
detect(entry) {
|
|
17
|
+
// Only count entry types that participate in normalize
|
|
18
|
+
return entry.type === 'user' || entry.type === 'assistant';
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
toIntermediate(entry, ctx) {
|
|
22
|
+
const { idx, rawEntries } = ctx;
|
|
23
|
+
const entryType = entry.type;
|
|
24
|
+
|
|
25
|
+
if (entryType !== 'user' && entryType !== 'assistant') {
|
|
26
|
+
return { idx, toolNames: [], adapterSkip: 'nonMessage' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const role = entry.message?.role || entryType;
|
|
30
|
+
|
|
31
|
+
if (role === 'toolResult') {
|
|
32
|
+
return { idx, toolNames: [], adapterSkip: 'toolResult' };
|
|
33
|
+
}
|
|
34
|
+
if (role !== 'user' && role !== 'assistant') {
|
|
35
|
+
return { idx, role: null, toolNames: [], adapterSkip: 'noRole' };
|
|
36
|
+
}
|
|
37
|
+
if (entry.isMeta) {
|
|
38
|
+
return { idx, toolNames: [], adapterSkip: 'meta' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { text, commandName, toolNames } = extractContent(entry.message);
|
|
42
|
+
|
|
43
|
+
// CLI internal command output tags
|
|
44
|
+
if (text.includes('<local-command-caveat>') || text.includes('<local-command-stdout>') || text.includes('<local-command-stderr>')) {
|
|
45
|
+
return { idx, toolNames, adapterSkip: 'caveat' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isInterrupt = text.startsWith('[Request interrupted by user');
|
|
49
|
+
|
|
50
|
+
// Tool-use-only assistant entry (no visible text, only tool calls)
|
|
51
|
+
if (!text && toolNames.length > 0 && role === 'assistant') {
|
|
52
|
+
return { idx, toolNames, adapterSkip: 'toolOnly' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Narration detection: short text entry immediately followed by a tool_use entry.
|
|
56
|
+
// Claude Code splits text and tool_use into separate JSONL entries.
|
|
57
|
+
// A short text before a tool call is narration ("Now reading X...", "Let me check...").
|
|
58
|
+
if (role === 'assistant' && text && text.length < MAX_NARRATION_CHARS) {
|
|
59
|
+
let nextIsTool = false;
|
|
60
|
+
for (let j = idx + 1; j < rawEntries.length && j < idx + 3; j++) {
|
|
61
|
+
const ne = rawEntries[j];
|
|
62
|
+
if (ne.type === 'assistant') {
|
|
63
|
+
const nc = ne.message?.content;
|
|
64
|
+
if (Array.isArray(nc) && nc.some(x => x.type === 'tool_use')) nextIsTool = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (nextIsTool) {
|
|
69
|
+
return { idx, toolNames, adapterSkip: 'narration' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
idx, role, text,
|
|
75
|
+
timestamp: parseTimestamp(entry),
|
|
76
|
+
toolNames, commandName, isInterrupt,
|
|
77
|
+
adapterSkip: null,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
routinePatterns: [
|
|
82
|
+
/^<task-notification>/,
|
|
83
|
+
],
|
|
84
|
+
|
|
85
|
+
skipCommands: [
|
|
86
|
+
'/model', '/cost', '/memory', '/permissions', '/diff', '/review',
|
|
87
|
+
'/doctor', '/login', '/logout', '/mcp', '/context', '/fast',
|
|
88
|
+
'/think', '/vim', '/exit',
|
|
89
|
+
],
|
|
90
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gateway adapter — for AI gateway servers that produce type='message' entries.
|
|
5
|
+
* Content blocks combine text + thinking + toolCall in a single entry.
|
|
6
|
+
* Supports channel metadata stripping (Discord, Telegram, etc.).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { extractContent } = require('../extract');
|
|
10
|
+
const { parseTimestamp } = require('../timestamp');
|
|
11
|
+
|
|
12
|
+
// Channel metadata prefix injected by gateway routing layers
|
|
13
|
+
const METADATA_PREFIX_RE = /^(?:Conversation info \(untrusted metadata\):[\s\S]*?```\s*\n\s*)?(?:Sender \(untrusted metadata\):[\s\S]*?```\s*\n\s*)?/;
|
|
14
|
+
|
|
15
|
+
function stripChannelMetadata(text) {
|
|
16
|
+
const stripped = text.replace(METADATA_PREFIX_RE, '').trim();
|
|
17
|
+
return stripped || text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
name: 'gateway',
|
|
22
|
+
|
|
23
|
+
detect(entry) {
|
|
24
|
+
return entry.type === 'message';
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
toIntermediate(entry, ctx) {
|
|
28
|
+
const { idx } = ctx;
|
|
29
|
+
|
|
30
|
+
if (entry.type !== 'message') {
|
|
31
|
+
return { idx, toolNames: [], adapterSkip: 'nonMessage' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const msg = entry.message;
|
|
35
|
+
const role = msg?.role;
|
|
36
|
+
|
|
37
|
+
if (role === 'toolResult') {
|
|
38
|
+
return { idx, toolNames: [], adapterSkip: 'toolResult' };
|
|
39
|
+
}
|
|
40
|
+
if (role !== 'user' && role !== 'assistant') {
|
|
41
|
+
return { idx, role: null, toolNames: [], adapterSkip: 'noRole' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { text, commandName, toolNames } = extractContent(msg);
|
|
45
|
+
|
|
46
|
+
let finalText = text;
|
|
47
|
+
const isInterrupt = text.startsWith('[Request interrupted by user');
|
|
48
|
+
if (role === 'user' && finalText && !isInterrupt) {
|
|
49
|
+
finalText = stripChannelMetadata(finalText);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
idx, role, text: finalText,
|
|
54
|
+
timestamp: parseTimestamp(entry),
|
|
55
|
+
toolNames, commandName, isInterrupt,
|
|
56
|
+
adapterSkip: null,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
routinePatterns: [
|
|
61
|
+
/^HEARTBEAT_OK$/,
|
|
62
|
+
/^THINK_OK$/,
|
|
63
|
+
/^\[Queued messages while agent was busy\]/,
|
|
64
|
+
],
|
|
65
|
+
|
|
66
|
+
skipCommands: [],
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Commands that produce no conversational value — skip entirely
|
|
4
|
+
const SKIP_COMMANDS = new Set(['/clear', '/compact', '/help', '/status', '/config']);
|
|
5
|
+
|
|
6
|
+
// Commands that mark session boundaries — keep as boundary markers
|
|
7
|
+
const RESET_COMMANDS = new Set(['/new', '/reset']);
|
|
8
|
+
|
|
9
|
+
const MAX_MSG_CHARS = 8000;
|
|
10
|
+
const MAX_NARRATION_CHARS = 200;
|
|
11
|
+
|
|
12
|
+
module.exports = { SKIP_COMMANDS, RESET_COMMANDS, MAX_MSG_CHARS, MAX_NARRATION_CHARS };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const gatewayAdapter = require('./adapters/gateway');
|
|
4
|
+
const claudeCodeAdapter = require('./adapters/claude-code');
|
|
5
|
+
|
|
6
|
+
const ADAPTERS = [gatewayAdapter, claudeCodeAdapter];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Auto-detect the client type from raw session entries.
|
|
10
|
+
* Samples the first 5 entries and picks the adapter with the most matches.
|
|
11
|
+
* @param {any[]} rawEntries
|
|
12
|
+
* @returns {string} Client name ('gateway' | 'claude-code')
|
|
13
|
+
* @throws {Error} If entries are empty, no adapter matches, or detection is ambiguous
|
|
14
|
+
*/
|
|
15
|
+
function detectClient(rawEntries) {
|
|
16
|
+
if (!rawEntries || rawEntries.length === 0) {
|
|
17
|
+
throw new Error('Cannot detect client: empty entries');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sample = rawEntries.slice(0, Math.min(5, rawEntries.length));
|
|
21
|
+
const scores = [];
|
|
22
|
+
|
|
23
|
+
for (const adapter of ADAPTERS) {
|
|
24
|
+
const count = sample.filter(e => adapter.detect(e)).length;
|
|
25
|
+
scores.push({ name: adapter.name, count });
|
|
26
|
+
}
|
|
27
|
+
scores.sort((a, b) => b.count - a.count);
|
|
28
|
+
|
|
29
|
+
if (scores[0].count === 0) {
|
|
30
|
+
throw new Error('Cannot detect session client type. Pass opts.client explicitly.');
|
|
31
|
+
}
|
|
32
|
+
if (scores.length > 1 && scores[0].count === scores[1].count) {
|
|
33
|
+
throw new Error(`Ambiguous client detection (${scores[0].name}=${scores[0].count}, ${scores[1].name}=${scores[1].count}). Pass opts.client explicitly.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return scores[0].name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get adapter by client name.
|
|
41
|
+
* @param {string} clientType
|
|
42
|
+
* @returns {object} Adapter object
|
|
43
|
+
* @throws {Error} If client type is unknown
|
|
44
|
+
*/
|
|
45
|
+
function getAdapter(clientType) {
|
|
46
|
+
for (const adapter of ADAPTERS) {
|
|
47
|
+
if (adapter.name === clientType) return adapter;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`Unknown client type: "${clientType}". Known: ${ADAPTERS.map(a => a.name).join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { detectClient, getAdapter, ADAPTERS };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Content extraction utilities shared across adapters
|
|
4
|
+
|
|
5
|
+
function extractCommandName(content) {
|
|
6
|
+
const match = typeof content === 'string'
|
|
7
|
+
? content.match(/<command-name>(\/\w+)<\/command-name>/)
|
|
8
|
+
: null;
|
|
9
|
+
return match ? match[1] : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract text, command name, and tool names from a message object.
|
|
14
|
+
* Handles both string content and content block arrays.
|
|
15
|
+
* @param {object} msg - Message object with .content field
|
|
16
|
+
* @returns {{ text: string, commandName: string|null, toolNames: string[] }}
|
|
17
|
+
*/
|
|
18
|
+
function extractContent(msg) {
|
|
19
|
+
if (!msg) return { text: '', commandName: null, toolNames: [] };
|
|
20
|
+
const content = msg.content;
|
|
21
|
+
let commandName = null;
|
|
22
|
+
const toolNames = [];
|
|
23
|
+
|
|
24
|
+
if (typeof content === 'string') {
|
|
25
|
+
commandName = extractCommandName(content);
|
|
26
|
+
return { text: content.trim(), commandName, toolNames };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
const texts = [];
|
|
31
|
+
for (const item of content) {
|
|
32
|
+
if (item.type === 'text' && item.text) {
|
|
33
|
+
const cmd = extractCommandName(item.text);
|
|
34
|
+
if (cmd) commandName = cmd;
|
|
35
|
+
texts.push(item.text);
|
|
36
|
+
}
|
|
37
|
+
// tool_use: Claude Code / Anthropic API format
|
|
38
|
+
// toolCall: gateway / OpenAI-style format
|
|
39
|
+
if ((item.type === 'tool_use' || item.type === 'toolCall') && item.name) {
|
|
40
|
+
toolNames.push(item.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { text: texts.join('\n').trim(), commandName, toolNames };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { text: '', commandName, toolNames };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { extractContent, extractCommandName };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SKIP_COMMANDS, RESET_COMMANDS, MAX_MSG_CHARS } = require('./constants');
|
|
4
|
+
const { detectClient, getAdapter } = require('./detect');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize raw session entries into effective messages.
|
|
8
|
+
*
|
|
9
|
+
* Accepts raw JSONL entries from any supported client (gateway, Claude Code, etc.)
|
|
10
|
+
* and produces a clean, uniform array of conversational messages suitable for
|
|
11
|
+
* summarization, embedding, and recall.
|
|
12
|
+
*
|
|
13
|
+
* @param {any[]} rawEntries - Raw JSONL entries from a session file
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {string} [opts.client] - Client type: 'gateway' | 'claude-code'. Auto-detected if omitted.
|
|
16
|
+
* @param {number} [opts.idleGapMs] - Idle gap threshold for boundary detection (default: 2 hours)
|
|
17
|
+
* @returns {{ normalized: object[], skipStats: object, boundaries: object[], toolsUsed: string[] }}
|
|
18
|
+
*/
|
|
19
|
+
function normalizeSession(rawEntries, opts = {}) {
|
|
20
|
+
if (!rawEntries || rawEntries.length === 0) {
|
|
21
|
+
return {
|
|
22
|
+
normalized: [],
|
|
23
|
+
skipStats: { total: 0, nonMessage: 0, noRole: 0, meta: 0, caveat: 0,
|
|
24
|
+
empty: 0, toolOnly: 0, narration: 0, toolResult: 0, routine: 0, command: 0 },
|
|
25
|
+
boundaries: [],
|
|
26
|
+
toolsUsed: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const idleGapMs = opts.idleGapMs || 2 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
// 1. Select adapter
|
|
33
|
+
const clientType = opts.client || detectClient(rawEntries);
|
|
34
|
+
const adapter = getAdapter(clientType);
|
|
35
|
+
|
|
36
|
+
// 2. Merge adapter-specific constants with shared constants
|
|
37
|
+
const allSkipCommands = new Set([...SKIP_COMMANDS, ...(adapter.skipCommands || [])]);
|
|
38
|
+
const allRoutinePatterns = [...(adapter.routinePatterns || [])];
|
|
39
|
+
|
|
40
|
+
// 3. Main loop: adapter.toIntermediate → shared filter → collect
|
|
41
|
+
const normalized = [];
|
|
42
|
+
const skipStats = { total: 0, nonMessage: 0, noRole: 0, meta: 0, caveat: 0,
|
|
43
|
+
empty: 0, toolOnly: 0, narration: 0, toolResult: 0, routine: 0, command: 0 };
|
|
44
|
+
const toolsUsed = new Set();
|
|
45
|
+
|
|
46
|
+
for (let idx = 0; idx < rawEntries.length; idx++) {
|
|
47
|
+
skipStats.total++;
|
|
48
|
+
const parsed = adapter.toIntermediate(rawEntries[idx], { idx, rawEntries });
|
|
49
|
+
|
|
50
|
+
// Collect tool names even from skipped entries
|
|
51
|
+
if (parsed.toolNames?.length) {
|
|
52
|
+
for (const tn of parsed.toolNames) toolsUsed.add(tn);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Adapter-determined skip
|
|
56
|
+
if (parsed.adapterSkip) {
|
|
57
|
+
if (!(parsed.adapterSkip in skipStats)) {
|
|
58
|
+
throw new Error(`Unknown adapterSkip reason: "${parsed.adapterSkip}" from ${clientType} adapter`);
|
|
59
|
+
}
|
|
60
|
+
skipStats[parsed.adapterSkip]++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Shared: invalid role
|
|
65
|
+
if (!parsed.role || (parsed.role !== 'user' && parsed.role !== 'assistant')) {
|
|
66
|
+
skipStats.noRole++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Shared: empty text (but keep interrupts)
|
|
71
|
+
if (!parsed.text && !parsed.isInterrupt) {
|
|
72
|
+
skipStats.empty++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Shared: routine patterns
|
|
77
|
+
if (!parsed.isInterrupt && parsed.text && allRoutinePatterns.some(re => re.test(parsed.text.trim()))) {
|
|
78
|
+
skipStats.routine++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Shared: skip commands
|
|
83
|
+
if (parsed.commandName && allSkipCommands.has(parsed.commandName)) {
|
|
84
|
+
skipStats.command++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Shared: truncate + reset command handling
|
|
89
|
+
const isResetCommand = !!(parsed.commandName && RESET_COMMANDS.has(parsed.commandName));
|
|
90
|
+
let finalText = isResetCommand ? '' : (parsed.text || '');
|
|
91
|
+
if (finalText.length > MAX_MSG_CHARS) {
|
|
92
|
+
finalText = finalText.slice(0, MAX_MSG_CHARS) + '\n[truncated]';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const msg = {
|
|
96
|
+
idx: parsed.idx,
|
|
97
|
+
role: parsed.role,
|
|
98
|
+
timestamp: parsed.timestamp,
|
|
99
|
+
text: finalText,
|
|
100
|
+
commandName: parsed.commandName || null,
|
|
101
|
+
isResetCommand,
|
|
102
|
+
};
|
|
103
|
+
if (parsed.isInterrupt) msg.isInterrupt = true;
|
|
104
|
+
|
|
105
|
+
normalized.push(msg);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Boundary detection
|
|
109
|
+
const boundaries = [];
|
|
110
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
111
|
+
const cur = normalized[i];
|
|
112
|
+
const prev = i > 0 ? normalized[i - 1] : null;
|
|
113
|
+
|
|
114
|
+
if (cur.isResetCommand) {
|
|
115
|
+
boundaries.push({ type: 'command', at_index: i, reason: cur.commandName });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (prev?.timestamp && cur.timestamp) {
|
|
119
|
+
const gapMs = new Date(cur.timestamp).getTime() - new Date(prev.timestamp).getTime();
|
|
120
|
+
if (gapMs > idleGapMs) {
|
|
121
|
+
boundaries.push({ type: 'idle_gap', at_index: i, gap_minutes: Math.round(gapMs / 60000) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { normalized, skipStats, boundaries, toolsUsed: [...toolsUsed] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { normalizeSession, detectClient };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse timestamp from a raw session entry.
|
|
5
|
+
* Handles multiple formats: ISO string (outer), epoch ms number (inner).
|
|
6
|
+
* Unified across all adapters to ensure consistent boundary detection.
|
|
7
|
+
* @param {object} entry - Raw session entry
|
|
8
|
+
* @returns {string|null} ISO8601 string or null
|
|
9
|
+
*/
|
|
10
|
+
function parseTimestamp(entry) {
|
|
11
|
+
// Outer timestamp (ISO string) — common in CLI-based clients
|
|
12
|
+
const outerTs = entry.timestamp;
|
|
13
|
+
if (typeof outerTs === 'string') {
|
|
14
|
+
const d = new Date(outerTs);
|
|
15
|
+
if (!isNaN(d.getTime())) return d.toISOString();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Inner timestamp (epoch ms) — common in gateway/server-side clients
|
|
19
|
+
const innerTs = entry.message?.timestamp;
|
|
20
|
+
if (typeof innerTs === 'number') {
|
|
21
|
+
return new Date(innerTs).toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Inner timestamp can also be ISO string
|
|
25
|
+
if (typeof innerTs === 'string') {
|
|
26
|
+
const d = new Date(innerTs);
|
|
27
|
+
if (!isNaN(d.getTime())) return d.toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { parseTimestamp };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { httpRequest, withRetry } = require('./_http');
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Custom adapter
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function validateResults(results) {
|
|
10
|
+
return results.filter(r =>
|
|
11
|
+
r && typeof r.index === 'number' && Number.isFinite(r.index)
|
|
12
|
+
&& typeof r.score === 'number' && Number.isFinite(r.score)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createCustomReranker(config) {
|
|
17
|
+
const fn = config.fn;
|
|
18
|
+
if (!fn) throw new Error('fn is required for custom reranker');
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
async rerank(query, documents, opts = {}) {
|
|
22
|
+
if (!query || !documents || documents.length === 0) return [];
|
|
23
|
+
const topN = opts.topN || documents.length;
|
|
24
|
+
const results = await fn({ query, documents, topN });
|
|
25
|
+
if (!Array.isArray(results)) throw new Error('Custom reranker fn must return an array');
|
|
26
|
+
return validateResults(results).sort((a, b) => b.score - a.score);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// TEI adapter (HuggingFace Text Embeddings Inference)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function createTEIReranker(config) {
|
|
36
|
+
const baseUrl = (config.teiBaseUrl || config.baseUrl || 'http://localhost:8080').replace(/\/+$/, '');
|
|
37
|
+
const timeout = config.timeout || 2000;
|
|
38
|
+
const maxRetries = config.maxRetries ?? 1;
|
|
39
|
+
const initialBackoffMs = config.initialBackoffMs || 250;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
async rerank(query, documents, opts = {}) {
|
|
43
|
+
if (!query || !documents || documents.length === 0) return [];
|
|
44
|
+
|
|
45
|
+
const result = await withRetry(
|
|
46
|
+
() => httpRequest(`${baseUrl}/rerank`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
timeout,
|
|
50
|
+
}, { query, texts: documents, raw_scores: false }),
|
|
51
|
+
{ maxRetries, initialBackoffMs },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// TEI returns array of { index, score }
|
|
55
|
+
const arr = Array.isArray(result) ? result : [];
|
|
56
|
+
return validateResults(arr.map(r => ({ index: r.index, score: r.score })))
|
|
57
|
+
.sort((a, b) => b.score - a.score);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Jina adapter
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function createJinaReranker(config) {
|
|
67
|
+
const apiKey = config.jinaApiKey;
|
|
68
|
+
if (!apiKey) throw new Error('jinaApiKey is required for Jina reranker');
|
|
69
|
+
|
|
70
|
+
const model = config.jinaModel || 'jina-reranker-v2-base-multilingual';
|
|
71
|
+
const baseUrl = (config.jinaBaseUrl || 'https://api.jina.ai/v1/rerank').replace(/\/+$/, '');
|
|
72
|
+
const timeout = config.timeout || 2000;
|
|
73
|
+
const maxRetries = config.maxRetries ?? 1;
|
|
74
|
+
const initialBackoffMs = config.initialBackoffMs || 250;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async rerank(query, documents, opts = {}) {
|
|
78
|
+
if (!query || !documents || documents.length === 0) return [];
|
|
79
|
+
const topN = opts.topN || documents.length;
|
|
80
|
+
|
|
81
|
+
const result = await withRetry(
|
|
82
|
+
() => httpRequest(baseUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
87
|
+
},
|
|
88
|
+
timeout,
|
|
89
|
+
}, { model, query, documents, top_n: topN }),
|
|
90
|
+
{ maxRetries, initialBackoffMs },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Jina returns { results: [{ index, relevance_score }] }
|
|
94
|
+
const arr = result.results || [];
|
|
95
|
+
return validateResults(arr.map(r => ({ index: r.index, score: r.relevance_score })))
|
|
96
|
+
.sort((a, b) => b.score - a.score);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// OpenRouter adapter (Cohere rerank etc. via OpenRouter)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function createOpenRouterReranker(config) {
|
|
106
|
+
const apiKey = config.openrouterApiKey || config.apiKey;
|
|
107
|
+
if (!apiKey) throw new Error('openrouterApiKey is required for OpenRouter reranker');
|
|
108
|
+
|
|
109
|
+
const model = config.model || 'cohere/rerank-v3.5';
|
|
110
|
+
const baseUrl = (config.openrouterBaseUrl || 'https://openrouter.ai/api/v1/rerank').replace(/\/+$/, '');
|
|
111
|
+
const timeout = config.timeout || 5000;
|
|
112
|
+
const maxRetries = config.maxRetries ?? 1;
|
|
113
|
+
const initialBackoffMs = config.initialBackoffMs || 250;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
async rerank(query, documents, opts = {}) {
|
|
117
|
+
if (!query || !documents || documents.length === 0) return [];
|
|
118
|
+
const topN = opts.topN || documents.length;
|
|
119
|
+
|
|
120
|
+
const result = await withRetry(
|
|
121
|
+
() => httpRequest(baseUrl, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
126
|
+
},
|
|
127
|
+
timeout,
|
|
128
|
+
}, { model, query, documents, top_n: topN }),
|
|
129
|
+
{ maxRetries, initialBackoffMs },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// OpenRouter returns { results: [{ index, relevance_score }] }
|
|
133
|
+
const arr = result.results || [];
|
|
134
|
+
return validateResults(arr.map(r => ({ index: r.index, score: r.relevance_score })))
|
|
135
|
+
.sort((a, b) => b.score - a.score);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Factory
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function createReranker(config = {}) {
|
|
145
|
+
const provider = config.provider || 'custom';
|
|
146
|
+
|
|
147
|
+
switch (provider) {
|
|
148
|
+
case 'custom':
|
|
149
|
+
return createCustomReranker(config);
|
|
150
|
+
case 'tei':
|
|
151
|
+
return createTEIReranker(config);
|
|
152
|
+
case 'jina':
|
|
153
|
+
return createJinaReranker(config);
|
|
154
|
+
case 'openrouter':
|
|
155
|
+
return createOpenRouterReranker(config);
|
|
156
|
+
default:
|
|
157
|
+
throw new Error(`Unknown rerank provider: ${provider}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { createReranker };
|