@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21
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/CHANGELOG.md +330 -0
- package/SKILL.md +50 -83
- package/api-client.ts +18 -11
- package/config.ts +117 -3
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +606 -0
- package/dist/config.js +280 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedding.js +86 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +563 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5348 -0
- package/dist/llm-client.js +686 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +542 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +442 -0
- package/dist/retype-setscope.js +348 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedding.ts +32 -9
- package/fs-helpers.ts +124 -0
- package/gateway-url.ts +57 -9
- package/index.ts +586 -357
- package/llm-client.ts +211 -23
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +19 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/qa-bug-report.ts +381 -0
- package/relay-headers.ts +50 -0
- package/reranker.ts +73 -0
- package/retype-setscope.ts +12 -0
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +109 -16
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BaseImportAdapter } from './base-adapter.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Pattern for lines that start with a date prefix.
|
|
6
|
+
* Claude memory entries sometimes have: [2026-03-15] - User prefers TypeScript
|
|
7
|
+
*/
|
|
8
|
+
const DATE_PREFIX_RE = /^\[(\d{4}-\d{2}-\d{2})\]\s*[-:]\s*/;
|
|
9
|
+
/**
|
|
10
|
+
* Pattern for bullet-prefixed lines.
|
|
11
|
+
*/
|
|
12
|
+
const BULLET_PREFIX_RE = /^[-*\u2022]\s+/;
|
|
13
|
+
/**
|
|
14
|
+
* Pattern for numbered list lines.
|
|
15
|
+
*/
|
|
16
|
+
const NUMBERED_PREFIX_RE = /^\d+[.)]\s+/;
|
|
17
|
+
/** Maximum messages per conversation chunk for LLM extraction. */
|
|
18
|
+
const CHUNK_SIZE = 20;
|
|
19
|
+
export class ClaudeAdapter extends BaseImportAdapter {
|
|
20
|
+
source = 'claude';
|
|
21
|
+
displayName = 'Claude';
|
|
22
|
+
async parse(input, onProgress) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const errors = [];
|
|
25
|
+
let content;
|
|
26
|
+
if (input.content) {
|
|
27
|
+
content = input.content;
|
|
28
|
+
}
|
|
29
|
+
else if (input.file_path) {
|
|
30
|
+
try {
|
|
31
|
+
const resolvedPath = input.file_path.replace(/^~/, os.homedir());
|
|
32
|
+
content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
errors.push(`Failed to read file: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
36
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
errors.push('Claude import requires either content (pasted text) or file_path. ' +
|
|
41
|
+
'Copy your memories from Claude: Settings -> Memory -> select all and copy.');
|
|
42
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
43
|
+
}
|
|
44
|
+
// Claude memory export is plain text, one fact per line.
|
|
45
|
+
return this.parseMemoriesText(content.trim(), warnings, errors, onProgress);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse Claude memories — plain text, one memory per line.
|
|
49
|
+
* Returns conversation chunks for LLM extraction (no pattern matching).
|
|
50
|
+
*
|
|
51
|
+
* Each line is cleaned (date prefixes, bullets, numbers stripped) and
|
|
52
|
+
* grouped into chunks for the LLM to process.
|
|
53
|
+
*/
|
|
54
|
+
parseMemoriesText(content, warnings, errors, onProgress) {
|
|
55
|
+
// Split by newlines and filter
|
|
56
|
+
const lines = content.split('\n')
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => line.length > 0)
|
|
59
|
+
// Skip common header lines
|
|
60
|
+
.filter((line) => !/^(?:memories?|claude memories?|my memories?|saved memories?):?\s*$/i.test(line));
|
|
61
|
+
if (onProgress) {
|
|
62
|
+
onProgress({
|
|
63
|
+
current: 0,
|
|
64
|
+
total: lines.length,
|
|
65
|
+
phase: 'parsing',
|
|
66
|
+
message: `Parsing ${lines.length} Claude memories...`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Clean each line: extract date, strip formatting
|
|
70
|
+
const cleanedEntries = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
let cleaned = line;
|
|
73
|
+
let timestamp;
|
|
74
|
+
// Extract date prefix if present
|
|
75
|
+
const dateMatch = cleaned.match(DATE_PREFIX_RE);
|
|
76
|
+
if (dateMatch) {
|
|
77
|
+
timestamp = dateMatch[1];
|
|
78
|
+
cleaned = cleaned.replace(DATE_PREFIX_RE, '');
|
|
79
|
+
}
|
|
80
|
+
// Strip bullet/numbering markers
|
|
81
|
+
cleaned = cleaned
|
|
82
|
+
.replace(BULLET_PREFIX_RE, '')
|
|
83
|
+
.replace(NUMBERED_PREFIX_RE, '')
|
|
84
|
+
.trim();
|
|
85
|
+
if (cleaned.length >= 3) {
|
|
86
|
+
cleanedEntries.push({ text: cleaned, timestamp });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Group memories into chunks of CHUNK_SIZE for efficient LLM extraction
|
|
90
|
+
const chunks = [];
|
|
91
|
+
for (let i = 0; i < cleanedEntries.length; i += CHUNK_SIZE) {
|
|
92
|
+
const batch = cleanedEntries.slice(i, i + CHUNK_SIZE);
|
|
93
|
+
// Use the timestamp from the first entry in the batch (if available)
|
|
94
|
+
const batchTimestamp = batch.find((e) => e.timestamp)?.timestamp;
|
|
95
|
+
chunks.push({
|
|
96
|
+
title: `Claude memories (${i + 1}-${Math.min(i + CHUNK_SIZE, cleanedEntries.length)})`,
|
|
97
|
+
messages: batch.map((entry) => ({ role: 'user', text: entry.text })),
|
|
98
|
+
timestamp: batchTimestamp,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
facts: [],
|
|
103
|
+
chunks,
|
|
104
|
+
totalMessages: cleanedEntries.length,
|
|
105
|
+
warnings,
|
|
106
|
+
errors,
|
|
107
|
+
source_metadata: {
|
|
108
|
+
format: 'memories-text',
|
|
109
|
+
total_lines: lines.length,
|
|
110
|
+
chunks_count: chunks.length,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { BaseImportAdapter } from './base-adapter.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
/** Maximum messages per conversation chunk for LLM extraction. */
|
|
5
|
+
const CHUNK_SIZE = 20;
|
|
6
|
+
/** Gap (in minutes) between entries that starts a new pseudo-session. */
|
|
7
|
+
const SESSION_GAP_MINUTES = 30;
|
|
8
|
+
// ── Timestamp Parsing ────────────────────────────────────────────────────────
|
|
9
|
+
const MONTHS = {
|
|
10
|
+
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
|
|
11
|
+
Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Parse Gemini timestamp: "1 Apr 2026, 18:39:35 WEST" → ISO 8601.
|
|
15
|
+
* Timezone is treated as UTC (all entries use the same TZ, preserving order).
|
|
16
|
+
*/
|
|
17
|
+
function parseTimestamp(raw) {
|
|
18
|
+
const m = raw.match(/^(\d{1,2})\s+(\w{3})\s+(\d{4}),\s+(\d{2}):(\d{2}):(\d{2})\s+/);
|
|
19
|
+
if (!m || MONTHS[m[2]] === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const d = new Date(Date.UTC(+m[3], MONTHS[m[2]], +m[1], +m[4], +m[5], +m[6]));
|
|
22
|
+
return d.toISOString();
|
|
23
|
+
}
|
|
24
|
+
// ── HTML Helpers ─────────────────────────────────────────────────────────────
|
|
25
|
+
function decodeEntities(t) {
|
|
26
|
+
return t.replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ');
|
|
28
|
+
}
|
|
29
|
+
function stripHTML(html) {
|
|
30
|
+
return html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n')
|
|
31
|
+
.replace(/<\/li>/gi, '\n').replace(/<\/h[1-6]>/gi, '\n')
|
|
32
|
+
.replace(/<hr\s*\/?>/gi, '\n---\n').replace(/<[^>]+>/g, '')
|
|
33
|
+
.replace(/\n{3,}/g, '\n\n').trim();
|
|
34
|
+
}
|
|
35
|
+
// ── Gemini Adapter ───────────────────────────────────────────────────────────
|
|
36
|
+
export class GeminiAdapter extends BaseImportAdapter {
|
|
37
|
+
source = 'gemini';
|
|
38
|
+
displayName = 'Google Gemini';
|
|
39
|
+
async parse(input, onProgress) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const errors = [];
|
|
42
|
+
let content;
|
|
43
|
+
if (input.content) {
|
|
44
|
+
content = input.content;
|
|
45
|
+
}
|
|
46
|
+
else if (input.file_path) {
|
|
47
|
+
try {
|
|
48
|
+
const resolved = input.file_path.replace(/^~/, os.homedir());
|
|
49
|
+
content = fs.readFileSync(resolved, 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
errors.push(`Failed to read file: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
53
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
errors.push('Gemini import requires either content or file_path. ' +
|
|
58
|
+
'Export from Google Takeout: takeout.google.com → select Gemini Apps → export. ' +
|
|
59
|
+
'Provide the "My Activity.html" file path.');
|
|
60
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
61
|
+
}
|
|
62
|
+
if (onProgress) {
|
|
63
|
+
onProgress({ current: 0, total: 0, phase: 'parsing', message: 'Parsing Gemini HTML...' });
|
|
64
|
+
}
|
|
65
|
+
// Parse HTML into entries
|
|
66
|
+
const entries = this.parseHTML(content);
|
|
67
|
+
if (entries.length === 0) {
|
|
68
|
+
warnings.push('No conversation entries found in the HTML file.');
|
|
69
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
70
|
+
}
|
|
71
|
+
// Group into pseudo-sessions by temporal proximity
|
|
72
|
+
const sessions = this.groupSessions(entries);
|
|
73
|
+
if (onProgress) {
|
|
74
|
+
onProgress({
|
|
75
|
+
current: 0,
|
|
76
|
+
total: sessions.length,
|
|
77
|
+
phase: 'parsing',
|
|
78
|
+
message: `Parsed ${entries.length} entries into ${sessions.length} sessions`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Build conversation chunks from sessions
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let totalMessages = 0;
|
|
84
|
+
for (const session of sessions) {
|
|
85
|
+
const messages = [];
|
|
86
|
+
for (const entry of session) {
|
|
87
|
+
if (entry.userPrompt)
|
|
88
|
+
messages.push({ role: 'user', text: entry.userPrompt });
|
|
89
|
+
if (entry.aiResponse)
|
|
90
|
+
messages.push({ role: 'assistant', text: entry.aiResponse });
|
|
91
|
+
}
|
|
92
|
+
if (messages.length === 0)
|
|
93
|
+
continue;
|
|
94
|
+
totalMessages += messages.length;
|
|
95
|
+
const timestamp = session[0].timestampISO;
|
|
96
|
+
// Sub-chunk large sessions
|
|
97
|
+
for (let i = 0; i < messages.length; i += CHUNK_SIZE) {
|
|
98
|
+
const batch = messages.slice(i, i + CHUNK_SIZE);
|
|
99
|
+
const chunkIdx = Math.floor(i / CHUNK_SIZE) + 1;
|
|
100
|
+
const totalChunks = Math.ceil(messages.length / CHUNK_SIZE);
|
|
101
|
+
const title = totalChunks > 1
|
|
102
|
+
? `Gemini session (part ${chunkIdx}/${totalChunks})`
|
|
103
|
+
: 'Gemini session';
|
|
104
|
+
chunks.push({ title, messages: batch, timestamp });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
facts: [],
|
|
109
|
+
chunks,
|
|
110
|
+
totalMessages,
|
|
111
|
+
warnings,
|
|
112
|
+
errors,
|
|
113
|
+
source_metadata: {
|
|
114
|
+
format: 'gemini-takeout-html',
|
|
115
|
+
total_entries: entries.length,
|
|
116
|
+
sessions_count: sessions.length,
|
|
117
|
+
chunks_count: chunks.length,
|
|
118
|
+
total_messages: totalMessages,
|
|
119
|
+
date_range: {
|
|
120
|
+
earliest: entries[0]?.timestampISO,
|
|
121
|
+
latest: entries[entries.length - 1]?.timestampISO,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Parse Gemini Takeout HTML into structured entries.
|
|
128
|
+
*
|
|
129
|
+
* Each outer-cell div contains: "Prompted USER_TEXT<br>TIMESTAMP<br>RESPONSE_HTML"
|
|
130
|
+
* all within one content-cell.
|
|
131
|
+
*/
|
|
132
|
+
parseHTML(html) {
|
|
133
|
+
const entries = [];
|
|
134
|
+
const cellPattern = /<div class="outer-cell[^"]*">([\s\S]*?)(?=<div class="outer-cell|$)/g;
|
|
135
|
+
let match;
|
|
136
|
+
while ((match = cellPattern.exec(html)) !== null) {
|
|
137
|
+
const cell = match[1];
|
|
138
|
+
// Only process "Prompted" entries (skip canvas, feedback)
|
|
139
|
+
const promptedIdx = cell.indexOf('Prompted\u00a0');
|
|
140
|
+
if (promptedIdx === -1)
|
|
141
|
+
continue;
|
|
142
|
+
// Extract timestamp
|
|
143
|
+
const tsMatch = cell.match(/(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/);
|
|
144
|
+
if (!tsMatch)
|
|
145
|
+
continue;
|
|
146
|
+
const timestampISO = parseTimestamp(tsMatch[1]);
|
|
147
|
+
if (!timestampISO)
|
|
148
|
+
continue;
|
|
149
|
+
// Split on timestamp to separate user prompt from AI response
|
|
150
|
+
const afterPrompted = cell.substring(promptedIdx + 'Prompted\u00a0'.length);
|
|
151
|
+
const tsPattern = /(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/;
|
|
152
|
+
const tsIdx = afterPrompted.search(tsPattern);
|
|
153
|
+
let userPrompt = '';
|
|
154
|
+
let aiResponse = '';
|
|
155
|
+
if (tsIdx > 0) {
|
|
156
|
+
userPrompt = stripHTML(decodeEntities(afterPrompted.substring(0, tsIdx))).trim();
|
|
157
|
+
const tsInner = afterPrompted.match(tsPattern);
|
|
158
|
+
if (tsInner) {
|
|
159
|
+
const afterTs = afterPrompted.substring(tsIdx + tsInner[0].length)
|
|
160
|
+
.replace(/^\s*<br\s*\/?>\s*/i, '');
|
|
161
|
+
const endDiv = afterTs.search(/<\/div>\s*<div class="content-cell/);
|
|
162
|
+
const rawResp = endDiv !== -1 ? afterTs.substring(0, endDiv) : afterTs;
|
|
163
|
+
aiResponse = stripHTML(decodeEntities(rawResp)).trim();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (userPrompt.length < 3 && aiResponse.length < 3)
|
|
167
|
+
continue;
|
|
168
|
+
entries.push({
|
|
169
|
+
userPrompt,
|
|
170
|
+
aiResponse,
|
|
171
|
+
timestampISO,
|
|
172
|
+
timestampUnix: Math.floor(new Date(timestampISO).getTime() / 1000),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Sort chronologically (HTML is newest-first)
|
|
176
|
+
entries.sort((a, b) => a.timestampUnix - b.timestampUnix);
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Group entries into pseudo-sessions by temporal proximity.
|
|
181
|
+
*/
|
|
182
|
+
groupSessions(entries) {
|
|
183
|
+
if (entries.length === 0)
|
|
184
|
+
return [];
|
|
185
|
+
const sessions = [];
|
|
186
|
+
let current = [entries[0]];
|
|
187
|
+
for (let i = 1; i < entries.length; i++) {
|
|
188
|
+
const gap = entries[i].timestampUnix - entries[i - 1].timestampUnix;
|
|
189
|
+
if (gap > SESSION_GAP_MINUTES * 60) {
|
|
190
|
+
sessions.push(current);
|
|
191
|
+
current = [entries[i]];
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
current.push(entries[i]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (current.length > 0)
|
|
198
|
+
sessions.push(current);
|
|
199
|
+
return sessions;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { BaseImportAdapter } from './base-adapter.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export { Mem0Adapter } from './mem0-adapter.js';
|
|
4
|
+
export { MCPMemoryAdapter } from './mcp-memory-adapter.js';
|
|
5
|
+
export { ChatGPTAdapter } from './chatgpt-adapter.js';
|
|
6
|
+
export { ClaudeAdapter } from './claude-adapter.js';
|
|
7
|
+
export { GeminiAdapter } from './gemini-adapter.js';
|
|
8
|
+
import { Mem0Adapter } from './mem0-adapter.js';
|
|
9
|
+
import { MCPMemoryAdapter } from './mcp-memory-adapter.js';
|
|
10
|
+
import { ChatGPTAdapter } from './chatgpt-adapter.js';
|
|
11
|
+
import { ClaudeAdapter } from './claude-adapter.js';
|
|
12
|
+
import { GeminiAdapter } from './gemini-adapter.js';
|
|
13
|
+
const ADAPTERS = {
|
|
14
|
+
'mem0': () => new Mem0Adapter(),
|
|
15
|
+
'mcp-memory': () => new MCPMemoryAdapter(),
|
|
16
|
+
'chatgpt': () => new ChatGPTAdapter(),
|
|
17
|
+
'claude': () => new ClaudeAdapter(),
|
|
18
|
+
'gemini': () => new GeminiAdapter(),
|
|
19
|
+
};
|
|
20
|
+
export function getAdapter(source) {
|
|
21
|
+
const factory = ADAPTERS[source];
|
|
22
|
+
if (!factory) {
|
|
23
|
+
throw new Error(`Unknown import source: ${source}. Valid sources: ${Object.keys(ADAPTERS).join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
return factory();
|
|
26
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { BaseImportAdapter } from './base-adapter.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
/**
|
|
6
|
+
* Entity type mapping to TotalReclaw fact types.
|
|
7
|
+
*/
|
|
8
|
+
const ENTITY_TYPE_MAP = {
|
|
9
|
+
person: 'fact',
|
|
10
|
+
project: 'fact',
|
|
11
|
+
organization: 'fact',
|
|
12
|
+
tool: 'preference',
|
|
13
|
+
technology: 'preference',
|
|
14
|
+
preference: 'preference',
|
|
15
|
+
goal: 'goal',
|
|
16
|
+
event: 'episodic',
|
|
17
|
+
decision: 'decision',
|
|
18
|
+
};
|
|
19
|
+
export class MCPMemoryAdapter extends BaseImportAdapter {
|
|
20
|
+
source = 'mcp-memory';
|
|
21
|
+
displayName = 'MCP Memory Server';
|
|
22
|
+
async parse(input, onProgress) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const errors = [];
|
|
25
|
+
let content;
|
|
26
|
+
if (input.content) {
|
|
27
|
+
content = input.content;
|
|
28
|
+
}
|
|
29
|
+
else if (input.file_path) {
|
|
30
|
+
try {
|
|
31
|
+
const resolvedPath = input.file_path.replace(/^~/, os.homedir());
|
|
32
|
+
content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
errors.push(`Failed to read file: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
36
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Try default MCP memory path
|
|
41
|
+
const defaultPath = path.join(os.homedir(), '.mcp-memory', 'memory.jsonl');
|
|
42
|
+
try {
|
|
43
|
+
content = fs.readFileSync(defaultPath, 'utf-8');
|
|
44
|
+
warnings.push(`Using default MCP memory path: ${defaultPath}`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
errors.push('No content, file_path, or file at default path (~/.mcp-memory/memory.jsonl). ' +
|
|
48
|
+
'Provide the memory.jsonl content or file path.');
|
|
49
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Parse JSONL records
|
|
53
|
+
const records = this.parseJSONL(content, errors);
|
|
54
|
+
if (onProgress) {
|
|
55
|
+
onProgress({
|
|
56
|
+
current: 0,
|
|
57
|
+
total: records.length,
|
|
58
|
+
phase: 'parsing',
|
|
59
|
+
message: `Parsing ${records.length} MCP Memory records...`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Separate entities and relations
|
|
63
|
+
const entities = new Map();
|
|
64
|
+
const relations = [];
|
|
65
|
+
for (const record of records) {
|
|
66
|
+
if (record.type === 'entity') {
|
|
67
|
+
// Later entities override earlier ones (append-only file)
|
|
68
|
+
entities.set(record.name, record);
|
|
69
|
+
}
|
|
70
|
+
else if (record.type === 'relation') {
|
|
71
|
+
relations.push(record);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Convert entities to facts
|
|
75
|
+
const rawFacts = [];
|
|
76
|
+
let entityIndex = 0;
|
|
77
|
+
for (const [name, entity] of entities) {
|
|
78
|
+
const factType = ENTITY_TYPE_MAP[entity.entityType.toLowerCase()] || 'fact';
|
|
79
|
+
for (const observation of entity.observations) {
|
|
80
|
+
// Prefix observation with entity name for context
|
|
81
|
+
// "Works at Acme Corp" -> "John works at Acme Corp"
|
|
82
|
+
const text = this.contextualizeObservation(name, observation);
|
|
83
|
+
rawFacts.push({
|
|
84
|
+
text,
|
|
85
|
+
type: factType,
|
|
86
|
+
importance: 6,
|
|
87
|
+
source: 'mcp-memory',
|
|
88
|
+
sourceId: `${name}:${entityIndex}`,
|
|
89
|
+
tags: [entity.entityType],
|
|
90
|
+
});
|
|
91
|
+
entityIndex++;
|
|
92
|
+
}
|
|
93
|
+
if (onProgress) {
|
|
94
|
+
onProgress({
|
|
95
|
+
current: rawFacts.length,
|
|
96
|
+
total: rawFacts.length + relations.length,
|
|
97
|
+
phase: 'parsing',
|
|
98
|
+
message: `Parsed ${rawFacts.length} facts from entities...`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Convert relations to facts
|
|
103
|
+
for (const rel of relations) {
|
|
104
|
+
// Only create a fact if both entities exist
|
|
105
|
+
if (!entities.has(rel.from) || !entities.has(rel.to)) {
|
|
106
|
+
warnings.push(`Relation references unknown entity: ${rel.from} -> ${rel.to}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const text = `${rel.from} ${this.humanizeRelationType(rel.relationType)} ${rel.to}`;
|
|
110
|
+
rawFacts.push({
|
|
111
|
+
text,
|
|
112
|
+
type: 'fact',
|
|
113
|
+
importance: 5,
|
|
114
|
+
source: 'mcp-memory',
|
|
115
|
+
sourceId: `rel:${rel.from}:${rel.relationType}:${rel.to}`,
|
|
116
|
+
tags: ['relation'],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const { facts, invalidCount } = this.validateFacts(rawFacts);
|
|
120
|
+
if (invalidCount > 0) {
|
|
121
|
+
warnings.push(`${invalidCount} observations had invalid/empty text and were skipped`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
facts,
|
|
125
|
+
chunks: [],
|
|
126
|
+
totalMessages: 0,
|
|
127
|
+
warnings,
|
|
128
|
+
errors,
|
|
129
|
+
source_metadata: {
|
|
130
|
+
entities_count: entities.size,
|
|
131
|
+
relations_count: relations.length,
|
|
132
|
+
observations_total: rawFacts.length,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parse JSONL content into records.
|
|
138
|
+
*/
|
|
139
|
+
parseJSONL(content, errors) {
|
|
140
|
+
const records = [];
|
|
141
|
+
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
142
|
+
for (let i = 0; i < lines.length; i++) {
|
|
143
|
+
try {
|
|
144
|
+
const record = JSON.parse(lines[i]);
|
|
145
|
+
if (record.type === 'entity' || record.type === 'relation') {
|
|
146
|
+
records.push(record);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Unknown record type — skip silently (future-proofing)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
errors.push(`Line ${i + 1}: Invalid JSON — skipped`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return records;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Add entity context to an observation.
|
|
160
|
+
*
|
|
161
|
+
* Heuristic: if the observation already starts with the entity name
|
|
162
|
+
* (case-insensitive) or a pronoun, return as-is. Otherwise prefix.
|
|
163
|
+
*
|
|
164
|
+
* Examples:
|
|
165
|
+
* - ("John", "Works at Acme Corp") -> "John works at Acme Corp"
|
|
166
|
+
* - ("John", "John likes TypeScript") -> "John likes TypeScript" (no double prefix)
|
|
167
|
+
* - ("John", "He prefers React") -> "John prefers React"
|
|
168
|
+
*/
|
|
169
|
+
contextualizeObservation(entityName, observation) {
|
|
170
|
+
const obsLower = observation.toLowerCase().trim();
|
|
171
|
+
const nameLower = entityName.toLowerCase();
|
|
172
|
+
// Already starts with the entity name
|
|
173
|
+
if (obsLower.startsWith(nameLower)) {
|
|
174
|
+
return observation.trim();
|
|
175
|
+
}
|
|
176
|
+
// Starts with a pronoun — replace it
|
|
177
|
+
const pronouns = ['he ', 'she ', 'they ', 'it ', 'his ', 'her ', 'their ', 'its '];
|
|
178
|
+
for (const pronoun of pronouns) {
|
|
179
|
+
if (obsLower.startsWith(pronoun)) {
|
|
180
|
+
return entityName + ' ' + observation.trim().slice(pronoun.length);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Starts with a verb (lowercase first word) — prefix entity name
|
|
184
|
+
const firstChar = observation.trim()[0];
|
|
185
|
+
if (firstChar && firstChar === firstChar.toLowerCase()) {
|
|
186
|
+
return `${entityName} ${observation.trim()}`;
|
|
187
|
+
}
|
|
188
|
+
// Observation is a standalone sentence — prefix with "About {entity}: "
|
|
189
|
+
return `${entityName}: ${observation.trim()}`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Convert relation type slug to human-readable text.
|
|
193
|
+
*
|
|
194
|
+
* "works_on" -> "works on"
|
|
195
|
+
* "MEMBER_OF" -> "is a member of"
|
|
196
|
+
*/
|
|
197
|
+
humanizeRelationType(relationType) {
|
|
198
|
+
const slug = relationType.toLowerCase().replace(/_/g, ' ');
|
|
199
|
+
// Common relation type mappings
|
|
200
|
+
const mappings = {
|
|
201
|
+
'works on': 'works on',
|
|
202
|
+
'works at': 'works at',
|
|
203
|
+
'member of': 'is a member of',
|
|
204
|
+
'belongs to': 'belongs to',
|
|
205
|
+
'created by': 'was created by',
|
|
206
|
+
'depends on': 'depends on',
|
|
207
|
+
'uses': 'uses',
|
|
208
|
+
'knows': 'knows',
|
|
209
|
+
'related to': 'is related to',
|
|
210
|
+
'part of': 'is part of',
|
|
211
|
+
'friend of': 'is friends with',
|
|
212
|
+
'manages': 'manages',
|
|
213
|
+
'reports to': 'reports to',
|
|
214
|
+
'located in': 'is located in',
|
|
215
|
+
'lives in': 'lives in',
|
|
216
|
+
};
|
|
217
|
+
return mappings[slug] || slug;
|
|
218
|
+
}
|
|
219
|
+
}
|