@lanonasis/recall-forge 1.1.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/.claw/skills/SKILL.md +347 -0
- package/CHANGELOG.md +162 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/SETUP.md +190 -0
- package/dist/cli-common.d.ts +25 -0
- package/dist/cli-common.js +338 -0
- package/dist/cli-memory.d.ts +6 -0
- package/dist/cli-memory.js +146 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +135 -0
- package/dist/client.d.ts +116 -0
- package/dist/client.js +643 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +125 -0
- package/dist/enrichment/capture-filter.d.ts +4 -0
- package/dist/enrichment/capture-filter.js +44 -0
- package/dist/enrichment/prompt-safety.d.ts +13 -0
- package/dist/enrichment/prompt-safety.js +83 -0
- package/dist/enrichment/tag-extractor.d.ts +1 -0
- package/dist/enrichment/tag-extractor.js +47 -0
- package/dist/enrichment/type-detector.d.ts +2 -0
- package/dist/enrichment/type-detector.js +95 -0
- package/dist/extraction/cli-extract.d.ts +8 -0
- package/dist/extraction/cli-extract.js +66 -0
- package/dist/extraction/format-adapters.d.ts +8 -0
- package/dist/extraction/format-adapters.js +268 -0
- package/dist/extraction/index.d.ts +7 -0
- package/dist/extraction/index.js +7 -0
- package/dist/extraction/jsonl-extractor.d.ts +32 -0
- package/dist/extraction/jsonl-extractor.js +207 -0
- package/dist/extraction/markdown-extractor.d.ts +23 -0
- package/dist/extraction/markdown-extractor.js +228 -0
- package/dist/extraction/secret-redactor.d.ts +7 -0
- package/dist/extraction/secret-redactor.js +112 -0
- package/dist/extraction/sqlite-extractor.d.ts +15 -0
- package/dist/extraction/sqlite-extractor.js +245 -0
- package/dist/extraction/types.d.ts +50 -0
- package/dist/extraction/types.js +1 -0
- package/dist/hooks/capture.d.ts +23 -0
- package/dist/hooks/capture.js +162 -0
- package/dist/hooks/context-engine.d.ts +4 -0
- package/dist/hooks/context-engine.js +54 -0
- package/dist/hooks/local-fallback.d.ts +5 -0
- package/dist/hooks/local-fallback.js +31 -0
- package/dist/hooks/recall.d.ts +21 -0
- package/dist/hooks/recall.js +123 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +103 -0
- package/dist/plugin-sdk-stub.d.ts +53 -0
- package/dist/plugin-sdk-stub.js +3 -0
- package/dist/privacy/privacy-guard.d.ts +33 -0
- package/dist/privacy/privacy-guard.js +130 -0
- package/dist/privacy/privacy-log.d.ts +6 -0
- package/dist/privacy/privacy-log.js +44 -0
- package/dist/tools/memory-forget.d.ts +3 -0
- package/dist/tools/memory-forget.js +109 -0
- package/dist/tools/memory-get.d.ts +3 -0
- package/dist/tools/memory-get.js +46 -0
- package/dist/tools/memory-search.d.ts +4 -0
- package/dist/tools/memory-search.js +95 -0
- package/dist/tools/memory-store.d.ts +5 -0
- package/dist/tools/memory-store.js +199 -0
- package/openclaw.plugin.json +315 -0
- package/package.json +90 -0
- package/setup/agents-memory.md +63 -0
- package/setup/heartbeat-memory.md +53 -0
- package/setup/install.sh +179 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Pluggable JSONL format parsers
|
|
2
|
+
// Each adapter detects and extracts content from a specific JSONL format
|
|
3
|
+
// Shared: extract text from Claude-style message content (string or content blocks)
|
|
4
|
+
function extractMessageText(msg) {
|
|
5
|
+
const texts = [];
|
|
6
|
+
const content = msg.content;
|
|
7
|
+
if (typeof content === "string") {
|
|
8
|
+
texts.push(content);
|
|
9
|
+
}
|
|
10
|
+
else if (Array.isArray(content)) {
|
|
11
|
+
for (const block of content) {
|
|
12
|
+
const b = block;
|
|
13
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
14
|
+
texts.push(b.text);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return texts;
|
|
19
|
+
}
|
|
20
|
+
const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text", "markdown"]);
|
|
21
|
+
function extractOpenClawSessionText(value) {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
return value.length > 0 ? [value] : [];
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.flatMap((entry) => extractOpenClawSessionText(entry));
|
|
27
|
+
}
|
|
28
|
+
if (!value || typeof value !== "object") {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const record = value;
|
|
32
|
+
const texts = [];
|
|
33
|
+
const blockType = typeof record.type === "string" ? record.type : undefined;
|
|
34
|
+
if (typeof record.text === "string" && (!blockType || TEXT_BLOCK_TYPES.has(blockType))) {
|
|
35
|
+
texts.push(record.text);
|
|
36
|
+
}
|
|
37
|
+
if ("content" in record) {
|
|
38
|
+
texts.push(...extractOpenClawSessionText(record.content));
|
|
39
|
+
}
|
|
40
|
+
if ("message" in record && typeof record.message !== "object") {
|
|
41
|
+
texts.push(...extractOpenClawSessionText(record.message));
|
|
42
|
+
}
|
|
43
|
+
if (typeof record.body === "string") {
|
|
44
|
+
texts.push(record.body);
|
|
45
|
+
}
|
|
46
|
+
if (typeof record.prompt === "string") {
|
|
47
|
+
texts.push(record.prompt);
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(texts.filter((text) => text.length > 0))];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Claude Code session JSONL format
|
|
53
|
+
* Lines have: { role: "user"|"assistant"|"system", content: string|ContentBlock[] }
|
|
54
|
+
*/
|
|
55
|
+
const claudeCodeAdapter = {
|
|
56
|
+
name: "claude-code",
|
|
57
|
+
detect(sample) {
|
|
58
|
+
return (typeof sample.role === "string" &&
|
|
59
|
+
["user", "assistant", "system"].includes(sample.role) &&
|
|
60
|
+
("content" in sample || "type" in sample));
|
|
61
|
+
},
|
|
62
|
+
extract(line, lineNumber) {
|
|
63
|
+
const role = line.role;
|
|
64
|
+
const normalizedRole = role === "user" || role === "assistant" || role === "system"
|
|
65
|
+
? role
|
|
66
|
+
: "unknown";
|
|
67
|
+
const texts = extractMessageText(line);
|
|
68
|
+
const timestamp = (line.timestamp ?? line.created_at);
|
|
69
|
+
return texts
|
|
70
|
+
.filter((t) => t.length > 0)
|
|
71
|
+
.map((text) => ({
|
|
72
|
+
text,
|
|
73
|
+
role: normalizedRole,
|
|
74
|
+
sourceFormat: "claude-code",
|
|
75
|
+
lineNumber,
|
|
76
|
+
timestamp,
|
|
77
|
+
}));
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* OpenClaw cache-trace JSONL format
|
|
82
|
+
* Lines may have request/response payloads, tool outputs, etc.
|
|
83
|
+
*/
|
|
84
|
+
const openclawCacheAdapter = {
|
|
85
|
+
name: "openclaw-cache",
|
|
86
|
+
detect(sample) {
|
|
87
|
+
return ("cache_key" in sample ||
|
|
88
|
+
"trace_id" in sample ||
|
|
89
|
+
("request" in sample && "response" in sample) ||
|
|
90
|
+
("tool" in sample && "output" in sample));
|
|
91
|
+
},
|
|
92
|
+
extract(line, lineNumber) {
|
|
93
|
+
const records = [];
|
|
94
|
+
const timestamp = (line.timestamp ?? line.ts ?? line.created_at);
|
|
95
|
+
// Extract from request body
|
|
96
|
+
if (line.request && typeof line.request === "object") {
|
|
97
|
+
const req = line.request;
|
|
98
|
+
const texts = extractMessageText(req);
|
|
99
|
+
if (typeof req.body === "string")
|
|
100
|
+
texts.push(req.body);
|
|
101
|
+
if (typeof req.prompt === "string")
|
|
102
|
+
texts.push(req.prompt);
|
|
103
|
+
for (const text of texts.filter((t) => t.length > 0)) {
|
|
104
|
+
records.push({
|
|
105
|
+
text,
|
|
106
|
+
role: "user",
|
|
107
|
+
sourceFormat: "openclaw-cache",
|
|
108
|
+
lineNumber,
|
|
109
|
+
timestamp,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Extract from response body
|
|
114
|
+
if (line.response && typeof line.response === "object") {
|
|
115
|
+
const res = line.response;
|
|
116
|
+
const texts = extractMessageText(res);
|
|
117
|
+
if (typeof res.body === "string")
|
|
118
|
+
texts.push(res.body);
|
|
119
|
+
if (typeof res.result === "string")
|
|
120
|
+
texts.push(res.result);
|
|
121
|
+
for (const text of texts.filter((t) => t.length > 0)) {
|
|
122
|
+
records.push({
|
|
123
|
+
text,
|
|
124
|
+
role: "assistant",
|
|
125
|
+
sourceFormat: "openclaw-cache",
|
|
126
|
+
lineNumber,
|
|
127
|
+
timestamp,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Extract from tool output
|
|
132
|
+
if (typeof line.output === "string" && line.output.length > 0) {
|
|
133
|
+
records.push({
|
|
134
|
+
text: line.output,
|
|
135
|
+
role: "assistant",
|
|
136
|
+
sourceFormat: "openclaw-cache",
|
|
137
|
+
lineNumber,
|
|
138
|
+
timestamp,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// Extract from top-level content
|
|
142
|
+
if (typeof line.content === "string" && line.content.length > 0) {
|
|
143
|
+
records.push({
|
|
144
|
+
text: line.content,
|
|
145
|
+
role: "unknown",
|
|
146
|
+
sourceFormat: "openclaw-cache",
|
|
147
|
+
lineNumber,
|
|
148
|
+
timestamp,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return records;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* OpenClaw session JSONL format
|
|
156
|
+
* Lines have: { type: "message", message: { role, content[] } }
|
|
157
|
+
*/
|
|
158
|
+
const openclawSessionAdapter = {
|
|
159
|
+
name: "openclaw-session",
|
|
160
|
+
detect(sample) {
|
|
161
|
+
// Session header line (first line of OpenClaw session files)
|
|
162
|
+
if (sample.type === "session" && ("cwd" in sample || "id" in sample))
|
|
163
|
+
return true;
|
|
164
|
+
// Standard message line
|
|
165
|
+
if (sample.type !== "message" || !sample.message || typeof sample.message !== "object") {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const message = sample.message;
|
|
169
|
+
return typeof message.role === "string" && ("content" in message || "text" in message);
|
|
170
|
+
},
|
|
171
|
+
extract(line, lineNumber) {
|
|
172
|
+
// Skip non-message lines (session header, model_change, thinking_level_change, custom, etc.)
|
|
173
|
+
if (line.type !== "message" || !line.message || typeof line.message !== "object") {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const message = line.message;
|
|
177
|
+
const role = message.role;
|
|
178
|
+
const normalizedRole = role === "user" || role === "assistant" || role === "system"
|
|
179
|
+
? role
|
|
180
|
+
: "unknown";
|
|
181
|
+
const timestamp = (line.timestamp ?? line.ts ?? line.created_at ?? message.timestamp);
|
|
182
|
+
const texts = extractOpenClawSessionText(message.content ?? message.text ?? message);
|
|
183
|
+
return texts.map((text) => ({
|
|
184
|
+
text,
|
|
185
|
+
role: normalizedRole,
|
|
186
|
+
sourceFormat: "openclaw-session",
|
|
187
|
+
lineNumber,
|
|
188
|
+
timestamp,
|
|
189
|
+
}));
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Codex session JSONL format
|
|
194
|
+
* Lines have: { type: "message", sender: "user"|"agent", content: string }
|
|
195
|
+
*/
|
|
196
|
+
const codexAdapter = {
|
|
197
|
+
name: "codex",
|
|
198
|
+
detect(sample) {
|
|
199
|
+
return ((sample.type === "message" || sample.type === "rollout") &&
|
|
200
|
+
("sender" in sample || "agent" in sample));
|
|
201
|
+
},
|
|
202
|
+
extract(line, lineNumber) {
|
|
203
|
+
const records = [];
|
|
204
|
+
const timestamp = (line.timestamp ?? line.ts);
|
|
205
|
+
const sender = (line.sender ?? line.agent ?? "unknown");
|
|
206
|
+
const role = sender === "user" ? "user" : sender === "agent" ? "assistant" : "unknown";
|
|
207
|
+
if (typeof line.content === "string" && line.content.length > 0) {
|
|
208
|
+
records.push({ text: line.content, role: role, sourceFormat: "codex", lineNumber, timestamp });
|
|
209
|
+
}
|
|
210
|
+
if (typeof line.message === "string" && line.message.length > 0) {
|
|
211
|
+
records.push({ text: line.message, role: role, sourceFormat: "codex", lineNumber, timestamp });
|
|
212
|
+
}
|
|
213
|
+
return records;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Generic fallback — tries to find text in any JSON structure
|
|
218
|
+
*/
|
|
219
|
+
const genericAdapter = {
|
|
220
|
+
name: "generic",
|
|
221
|
+
detect() {
|
|
222
|
+
return true; // Always matches as fallback
|
|
223
|
+
},
|
|
224
|
+
extract(line, lineNumber) {
|
|
225
|
+
const records = [];
|
|
226
|
+
const timestamp = (line.timestamp ?? line.ts ?? line.created_at);
|
|
227
|
+
// Try common text field names
|
|
228
|
+
const textFields = ["content", "text", "message", "body", "prompt", "output", "result"];
|
|
229
|
+
for (const field of textFields) {
|
|
230
|
+
const val = line[field];
|
|
231
|
+
if (typeof val === "string" && val.length > 0) {
|
|
232
|
+
records.push({
|
|
233
|
+
text: val,
|
|
234
|
+
role: "unknown",
|
|
235
|
+
sourceFormat: "generic",
|
|
236
|
+
lineNumber,
|
|
237
|
+
timestamp,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return records;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
/** All adapters in detection priority order */
|
|
245
|
+
export const FORMAT_ADAPTERS = [
|
|
246
|
+
claudeCodeAdapter,
|
|
247
|
+
openclawCacheAdapter,
|
|
248
|
+
openclawSessionAdapter,
|
|
249
|
+
codexAdapter,
|
|
250
|
+
genericAdapter,
|
|
251
|
+
];
|
|
252
|
+
/**
|
|
253
|
+
* Auto-detect the format adapter for a JSONL file
|
|
254
|
+
* Uses the first successfully parsed line as a sample
|
|
255
|
+
*/
|
|
256
|
+
export function detectFormat(sample, forceFormat) {
|
|
257
|
+
if (forceFormat) {
|
|
258
|
+
const forced = FORMAT_ADAPTERS.find((a) => a.name === forceFormat);
|
|
259
|
+
if (forced)
|
|
260
|
+
return forced;
|
|
261
|
+
}
|
|
262
|
+
for (const adapter of FORMAT_ADAPTERS) {
|
|
263
|
+
if (adapter.detect(sample))
|
|
264
|
+
return adapter;
|
|
265
|
+
}
|
|
266
|
+
// Should never reach here — generic always matches
|
|
267
|
+
return genericAdapter;
|
|
268
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { extractJsonl, formatStats } from "./jsonl-extractor.js";
|
|
2
|
+
export { extractMarkdown, isMarkdownFile } from "./markdown-extractor.js";
|
|
3
|
+
export { extractSqlite, isSqliteFile } from "./sqlite-extractor.js";
|
|
4
|
+
export { redactSecrets, containsSecrets } from "./secret-redactor.js";
|
|
5
|
+
export { detectFormat, FORMAT_ADAPTERS } from "./format-adapters.js";
|
|
6
|
+
export { registerExtractCli } from "./cli-extract.js";
|
|
7
|
+
export type { ExtractionOptions, ExtractionStats, ExtractionRecord, RedactionResult, FormatAdapter, } from "./types.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Extraction pipeline — barrel export
|
|
2
|
+
export { extractJsonl, formatStats } from "./jsonl-extractor.js";
|
|
3
|
+
export { extractMarkdown, isMarkdownFile } from "./markdown-extractor.js";
|
|
4
|
+
export { extractSqlite, isSqliteFile } from "./sqlite-extractor.js";
|
|
5
|
+
export { redactSecrets, containsSecrets } from "./secret-redactor.js";
|
|
6
|
+
export { detectFormat, FORMAT_ADAPTERS } from "./format-adapters.js";
|
|
7
|
+
export { registerExtractCli } from "./cli-extract.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { LanonasisClient } from "../client.js";
|
|
2
|
+
import type { LanonasisConfig } from "../config.js";
|
|
3
|
+
import type { LocalFallbackWriter } from "../hooks/local-fallback.js";
|
|
4
|
+
import type { ExtractionOptions, ExtractionStats } from "./types.js";
|
|
5
|
+
export interface ExtractionDeps {
|
|
6
|
+
client: LanonasisClient;
|
|
7
|
+
config: LanonasisConfig;
|
|
8
|
+
logger: {
|
|
9
|
+
info(msg: string): void;
|
|
10
|
+
warn(msg: string): void;
|
|
11
|
+
};
|
|
12
|
+
fallback?: LocalFallbackWriter;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extract memories from a JSONL file with secret redaction
|
|
16
|
+
*
|
|
17
|
+
* Pipeline per record:
|
|
18
|
+
* 1. Parse JSONL line → format adapter → ExtractionRecord
|
|
19
|
+
* 2. Role filter (default: user only)
|
|
20
|
+
* 3. redactSecrets() — strips API keys, tokens, credentials
|
|
21
|
+
* 4. shouldCapture() — filters noise
|
|
22
|
+
* 5. looksLikePromptInjection() — safety check
|
|
23
|
+
* 6. detectMemoryType() + extractTags() — enrichment
|
|
24
|
+
* 7. vector dedup via searchMemories() — skip duplicates
|
|
25
|
+
* 8. createMemory() with idempotency key — store
|
|
26
|
+
* 9. Optional local markdown fallback
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractJsonl(options: ExtractionOptions, deps: ExtractionDeps): Promise<ExtractionStats>;
|
|
29
|
+
/**
|
|
30
|
+
* Format extraction stats as a human-readable report
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatStats(stats: ExtractionStats, dryRun: boolean): string;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Core streaming JSONL extraction engine
|
|
2
|
+
// Reads any JSONL source, redacts secrets, enriches, deduplicates, stores as memories
|
|
3
|
+
import { createReadStream } from "fs";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { createHash } from "crypto";
|
|
6
|
+
import { shouldCapture } from "../enrichment/capture-filter.js";
|
|
7
|
+
import { detectMemoryType } from "../enrichment/type-detector.js";
|
|
8
|
+
import { extractTags } from "../enrichment/tag-extractor.js";
|
|
9
|
+
import { looksLikePromptInjection } from "../enrichment/prompt-safety.js";
|
|
10
|
+
import { redactSecrets } from "./secret-redactor.js";
|
|
11
|
+
import { detectFormat } from "./format-adapters.js";
|
|
12
|
+
function idempotencyKey(filePath, lineNumber, textPrefix) {
|
|
13
|
+
return createHash("sha256")
|
|
14
|
+
.update(`${filePath}:${lineNumber}:${textPrefix}`)
|
|
15
|
+
.digest("hex")
|
|
16
|
+
.slice(0, 32);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Extract memories from a JSONL file with secret redaction
|
|
20
|
+
*
|
|
21
|
+
* Pipeline per record:
|
|
22
|
+
* 1. Parse JSONL line → format adapter → ExtractionRecord
|
|
23
|
+
* 2. Role filter (default: user only)
|
|
24
|
+
* 3. redactSecrets() — strips API keys, tokens, credentials
|
|
25
|
+
* 4. shouldCapture() — filters noise
|
|
26
|
+
* 5. looksLikePromptInjection() — safety check
|
|
27
|
+
* 6. detectMemoryType() + extractTags() — enrichment
|
|
28
|
+
* 7. vector dedup via searchMemories() — skip duplicates
|
|
29
|
+
* 8. createMemory() with idempotency key — store
|
|
30
|
+
* 9. Optional local markdown fallback
|
|
31
|
+
*/
|
|
32
|
+
export async function extractJsonl(options, deps) {
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
const stats = {
|
|
35
|
+
linesRead: 0,
|
|
36
|
+
linesParsed: 0,
|
|
37
|
+
linesSkipped: 0,
|
|
38
|
+
recordsExtracted: 0,
|
|
39
|
+
recordsFiltered: 0,
|
|
40
|
+
recordsDeduped: 0,
|
|
41
|
+
recordsStored: 0,
|
|
42
|
+
secretsRedacted: 0,
|
|
43
|
+
markdownWritten: 0,
|
|
44
|
+
errors: 0,
|
|
45
|
+
durationMs: 0,
|
|
46
|
+
};
|
|
47
|
+
const { filePath, format, channel = "jsonl-extract", dedup = true, dedupThreshold = 0.92, localFallback = false, dryRun = false, limit, strict = false, roles = ["user"], } = options;
|
|
48
|
+
let adapter = null;
|
|
49
|
+
let totalProcessed = 0;
|
|
50
|
+
// Stream the file line by line
|
|
51
|
+
const fileStream = createReadStream(filePath, { encoding: "utf-8" });
|
|
52
|
+
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
53
|
+
for await (const line of rl) {
|
|
54
|
+
stats.linesRead++;
|
|
55
|
+
// Check limit
|
|
56
|
+
if (limit && totalProcessed >= limit)
|
|
57
|
+
break;
|
|
58
|
+
// Skip empty lines
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
stats.linesSkipped++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Parse JSON
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(trimmed);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
stats.linesSkipped++;
|
|
71
|
+
stats.errors++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
stats.linesParsed++;
|
|
75
|
+
// Auto-detect format on first valid line
|
|
76
|
+
if (!adapter) {
|
|
77
|
+
adapter = detectFormat(parsed, format);
|
|
78
|
+
deps.logger.info(`Format detected: ${adapter.name}`);
|
|
79
|
+
}
|
|
80
|
+
// Extract records from this line
|
|
81
|
+
const records = adapter.extract(parsed, stats.linesRead);
|
|
82
|
+
stats.recordsExtracted += records.length;
|
|
83
|
+
// Process each record through the pipeline
|
|
84
|
+
for (const record of records) {
|
|
85
|
+
if (limit && totalProcessed >= limit)
|
|
86
|
+
break;
|
|
87
|
+
// Role filter
|
|
88
|
+
if (roles.length > 0 && !roles.includes(record.role))
|
|
89
|
+
continue;
|
|
90
|
+
// Step 1: Redact secrets FIRST — before any other processing
|
|
91
|
+
const redaction = redactSecrets(record.text);
|
|
92
|
+
stats.secretsRedacted += redaction.secretsFound;
|
|
93
|
+
const cleanText = redaction.text;
|
|
94
|
+
// Step 2: Capture filter
|
|
95
|
+
if (!shouldCapture(cleanText, { strict })) {
|
|
96
|
+
stats.recordsFiltered++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Step 3: Prompt injection check
|
|
100
|
+
if (looksLikePromptInjection(cleanText)) {
|
|
101
|
+
stats.recordsFiltered++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Step 4: Enrichment
|
|
105
|
+
const memoryType = detectMemoryType(cleanText);
|
|
106
|
+
const tags = [
|
|
107
|
+
...extractTags(cleanText),
|
|
108
|
+
"jsonl-extract",
|
|
109
|
+
record.sourceFormat,
|
|
110
|
+
];
|
|
111
|
+
// Step 5: Vector dedup (if enabled and not dry run)
|
|
112
|
+
if (dedup && !dryRun) {
|
|
113
|
+
try {
|
|
114
|
+
const existing = await deps.client.searchMemories({
|
|
115
|
+
query: cleanText.slice(0, 500),
|
|
116
|
+
threshold: dedupThreshold,
|
|
117
|
+
limit: 1,
|
|
118
|
+
});
|
|
119
|
+
if (existing && existing.length > 0) {
|
|
120
|
+
stats.recordsDeduped++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// Dedup failure is non-fatal — proceed to store
|
|
126
|
+
deps.logger.warn(`Dedup check failed: ${err instanceof Error ? err.message : "unknown"}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Step 6: Store
|
|
130
|
+
if (!dryRun) {
|
|
131
|
+
const title = cleanText.slice(0, 80).replace(/\s+/g, " ").trim();
|
|
132
|
+
const params = {
|
|
133
|
+
title,
|
|
134
|
+
content: cleanText,
|
|
135
|
+
type: memoryType,
|
|
136
|
+
tags,
|
|
137
|
+
metadata: {
|
|
138
|
+
agent_id: deps.config.agentId,
|
|
139
|
+
source: record.sourceFormat,
|
|
140
|
+
channel,
|
|
141
|
+
line_number: record.lineNumber,
|
|
142
|
+
captured_at: record.timestamp ?? new Date().toISOString(),
|
|
143
|
+
secrets_redacted: redaction.secretsFound,
|
|
144
|
+
},
|
|
145
|
+
idempotency_key: idempotencyKey(filePath, record.lineNumber, cleanText.slice(0, 200)),
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
await deps.client.createMemory(params);
|
|
149
|
+
stats.recordsStored++;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
stats.errors++;
|
|
153
|
+
deps.logger.warn(`Store failed at line ${record.lineNumber}: ${err instanceof Error ? err.message : "unknown"}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Dry run — count as "would store"
|
|
158
|
+
stats.recordsStored++;
|
|
159
|
+
}
|
|
160
|
+
// Step 7: Local markdown fallback
|
|
161
|
+
if (localFallback && deps.fallback) {
|
|
162
|
+
try {
|
|
163
|
+
const title = cleanText.slice(0, 80).replace(/\s+/g, " ").trim();
|
|
164
|
+
await deps.fallback.writeMemory(title, cleanText);
|
|
165
|
+
stats.markdownWritten++;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Non-fatal
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
totalProcessed++;
|
|
172
|
+
// Progress indicator every 500 records
|
|
173
|
+
if (totalProcessed % 500 === 0) {
|
|
174
|
+
deps.logger.info(`Progress: ${totalProcessed} records processed, ${stats.recordsStored} stored, ${stats.secretsRedacted} secrets redacted`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
stats.durationMs = Date.now() - startTime;
|
|
179
|
+
return stats;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Format extraction stats as a human-readable report
|
|
183
|
+
*/
|
|
184
|
+
export function formatStats(stats, dryRun) {
|
|
185
|
+
const lines = [
|
|
186
|
+
"",
|
|
187
|
+
dryRun ? "=== DRY RUN REPORT ===" : "=== EXTRACTION REPORT ===",
|
|
188
|
+
"",
|
|
189
|
+
`Lines read: ${stats.linesRead}`,
|
|
190
|
+
`Lines parsed: ${stats.linesParsed}`,
|
|
191
|
+
`Lines skipped: ${stats.linesSkipped}`,
|
|
192
|
+
"",
|
|
193
|
+
`Records extracted: ${stats.recordsExtracted}`,
|
|
194
|
+
`Records filtered: ${stats.recordsFiltered} (noise/injection)`,
|
|
195
|
+
`Records deduped: ${stats.recordsDeduped} (already in memory)`,
|
|
196
|
+
`Records stored: ${stats.recordsStored}${dryRun ? " (would store)" : ""}`,
|
|
197
|
+
"",
|
|
198
|
+
`Secrets redacted: ${stats.secretsRedacted}`,
|
|
199
|
+
`Errors: ${stats.errors}`,
|
|
200
|
+
"",
|
|
201
|
+
`Duration: ${(stats.durationMs / 1000).toFixed(1)}s`,
|
|
202
|
+
];
|
|
203
|
+
if (stats.markdownWritten > 0) {
|
|
204
|
+
lines.push(`Markdown written: ${stats.markdownWritten}`);
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtractionOptions, ExtractionStats } from "./types.js";
|
|
2
|
+
import type { ExtractionDeps } from "./jsonl-extractor.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detect whether a file is markdown based on extension.
|
|
5
|
+
* Used by the CLI to route to extractMarkdown() vs extractJsonl().
|
|
6
|
+
*/
|
|
7
|
+
export declare function isMarkdownFile(filePath: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Extract memories from a Markdown document.
|
|
10
|
+
*
|
|
11
|
+
* Pipeline per section:
|
|
12
|
+
* 1. Split file by headings → MarkdownSection[]
|
|
13
|
+
* 2. Convert to ExtractionRecord[]
|
|
14
|
+
* 3. Role filter (default: user only — all sections are "user")
|
|
15
|
+
* 4. redactSecrets()
|
|
16
|
+
* 5. shouldCapture() — filter noise
|
|
17
|
+
* 6. looksLikePromptInjection() — safety check
|
|
18
|
+
* 7. detectMemoryType() + extractTags() — enrichment
|
|
19
|
+
* 8. Vector dedup via searchMemories()
|
|
20
|
+
* 9. createMemory() with idempotency key
|
|
21
|
+
* 10. Optional local markdown fallback
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractMarkdown(options: ExtractionOptions, deps: ExtractionDeps): Promise<ExtractionStats>;
|