@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,162 @@
|
|
|
1
|
+
import { detectMemoryType } from "../enrichment/type-detector.js";
|
|
2
|
+
import { extractTags } from "../enrichment/tag-extractor.js";
|
|
3
|
+
import { shouldCapture } from "../enrichment/capture-filter.js";
|
|
4
|
+
// Extract text content from message (handles string and content blocks)
|
|
5
|
+
function extractMessageText(msg) {
|
|
6
|
+
const texts = [];
|
|
7
|
+
const content = msg.content;
|
|
8
|
+
if (typeof content === "string") {
|
|
9
|
+
texts.push(content);
|
|
10
|
+
}
|
|
11
|
+
else if (Array.isArray(content)) {
|
|
12
|
+
for (const block of content) {
|
|
13
|
+
const b = block;
|
|
14
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
15
|
+
texts.push(b.text);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return texts;
|
|
20
|
+
}
|
|
21
|
+
// Build embedding profile metadata when config has explicit profile info
|
|
22
|
+
function embeddingProfileMeta(cfg) {
|
|
23
|
+
const meta = {};
|
|
24
|
+
if (cfg.embeddingProfileId)
|
|
25
|
+
meta.embedding_profile_id = cfg.embeddingProfileId;
|
|
26
|
+
if (cfg.embeddingProvider)
|
|
27
|
+
meta.embedding_provider = cfg.embeddingProvider;
|
|
28
|
+
if (cfg.embeddingModel)
|
|
29
|
+
meta.embedding_model = cfg.embeddingModel;
|
|
30
|
+
if (cfg.embeddingDimensions > 0)
|
|
31
|
+
meta.embedding_dimensions = cfg.embeddingDimensions;
|
|
32
|
+
return meta;
|
|
33
|
+
}
|
|
34
|
+
// Types that should be routed to shared namespace when configured
|
|
35
|
+
const SHARED_TYPES = new Set(["knowledge", "project", "reference"]);
|
|
36
|
+
// Create memory with proper metadata (Phase 2: shared namespace routing)
|
|
37
|
+
function createMemoryParams(text, cfg, channel, guard) {
|
|
38
|
+
const guardResult = guard ? guard.process(text) : { content: text, report: null };
|
|
39
|
+
const safeContent = guardResult.content;
|
|
40
|
+
const type = detectMemoryType(safeContent);
|
|
41
|
+
const baseTags = extractTags(safeContent);
|
|
42
|
+
const privacyTags = guardResult.report && guard ? guard.tagsFrom(guardResult.report) : [];
|
|
43
|
+
const tags = [...new Set([...baseTags, ...privacyTags])];
|
|
44
|
+
const title = safeContent.slice(0, 80).replace(/\s+/g, " ").trim();
|
|
45
|
+
// Route knowledge/project/reference to shared namespace when configured
|
|
46
|
+
const isShared = cfg.sharedNamespace && SHARED_TYPES.has(type);
|
|
47
|
+
const privacyMeta = guardResult.report && guard ? guard.metaFrom(guardResult.report) : undefined;
|
|
48
|
+
return {
|
|
49
|
+
safeContent,
|
|
50
|
+
params: {
|
|
51
|
+
title,
|
|
52
|
+
content: safeContent,
|
|
53
|
+
type,
|
|
54
|
+
tags,
|
|
55
|
+
metadata: {
|
|
56
|
+
agent_id: cfg.agentId,
|
|
57
|
+
source: "openclaw",
|
|
58
|
+
channel,
|
|
59
|
+
captured_at: new Date().toISOString(),
|
|
60
|
+
...(isShared ? { namespace: cfg.sharedNamespace } : {}),
|
|
61
|
+
...embeddingProfileMeta(cfg),
|
|
62
|
+
...privacyMeta,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function createCaptureHook(client, cfg, logger, fallback, guard, privacyLog) {
|
|
68
|
+
return async (event) => {
|
|
69
|
+
try {
|
|
70
|
+
// 1. Skip if session failed
|
|
71
|
+
if (!event.success)
|
|
72
|
+
return;
|
|
73
|
+
// 2. Determine mode and cap
|
|
74
|
+
const strict = cfg.captureMode === "hybrid";
|
|
75
|
+
const cap = cfg.captureMode === "hybrid" ? 3 : 5;
|
|
76
|
+
// 3. Extract user messages only
|
|
77
|
+
const userTexts = [];
|
|
78
|
+
for (const msg of event.messages) {
|
|
79
|
+
if (!msg || typeof msg !== "object")
|
|
80
|
+
continue;
|
|
81
|
+
const m = msg;
|
|
82
|
+
if (m.role !== "user")
|
|
83
|
+
continue;
|
|
84
|
+
const texts = extractMessageText(m);
|
|
85
|
+
userTexts.push(...texts);
|
|
86
|
+
}
|
|
87
|
+
// 4. Filter with shouldCapture
|
|
88
|
+
const toCapture = userTexts.filter((text) => shouldCapture(text, { strict }));
|
|
89
|
+
// 5. Cap and create memories
|
|
90
|
+
const captured = toCapture.slice(0, cap);
|
|
91
|
+
const channel = cfg.defaultChannel;
|
|
92
|
+
const safeCaptured = [];
|
|
93
|
+
for (const text of captured) {
|
|
94
|
+
const { params, safeContent } = createMemoryParams(text, cfg, channel, guard);
|
|
95
|
+
safeCaptured.push(safeContent);
|
|
96
|
+
await client.createMemory(params);
|
|
97
|
+
if (privacyLog && params.metadata?.privacy) {
|
|
98
|
+
// Extract the report from metadata for logging — reconstruct minimal shape
|
|
99
|
+
const p = params.metadata.privacy;
|
|
100
|
+
await privacyLog.write({
|
|
101
|
+
secretsFound: p.secretsFound ?? 0,
|
|
102
|
+
secretTypes: p.secretTypes ?? [],
|
|
103
|
+
piiFound: !!(p.piiTypes),
|
|
104
|
+
piiTypes: p.piiTypes ?? [],
|
|
105
|
+
piiSensitivity: p.piiSensitivity ?? "none",
|
|
106
|
+
regulations: p.regulations ?? [],
|
|
107
|
+
action: p.action,
|
|
108
|
+
timestamp: p.timestamp,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 6. Local fallback (single append — uses already-sanitized content)
|
|
113
|
+
if (cfg.localFallback && safeCaptured.length > 0) {
|
|
114
|
+
const combined = safeCaptured.join("\n\n---\n\n");
|
|
115
|
+
await fallback.writeMemory(`Captured ${safeCaptured.length} memories`, combined);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
logger.warn(`capture-hook error: ${err instanceof Error ? err.message : "unknown"}`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function createCompactionCaptureHook(client, cfg, logger, guard) {
|
|
124
|
+
return async (event) => {
|
|
125
|
+
try {
|
|
126
|
+
// 1. Extract messages (from event or sessionFile)
|
|
127
|
+
let messages = event.messages || [];
|
|
128
|
+
// 2. Filter user messages with strict bar
|
|
129
|
+
const userTexts = [];
|
|
130
|
+
for (const msg of messages) {
|
|
131
|
+
if (!msg || typeof msg !== "object")
|
|
132
|
+
continue;
|
|
133
|
+
const m = msg;
|
|
134
|
+
if (m.role !== "user")
|
|
135
|
+
continue;
|
|
136
|
+
const texts = extractMessageText(m);
|
|
137
|
+
userTexts.push(...texts);
|
|
138
|
+
}
|
|
139
|
+
// 3. Strict filter + prioritize knowledge/project
|
|
140
|
+
const filtered = userTexts.filter((text) => shouldCapture(text, { strict: true }));
|
|
141
|
+
// Prioritize knowledge/project types
|
|
142
|
+
filtered.sort((a, b) => {
|
|
143
|
+
const typeA = detectMemoryType(a);
|
|
144
|
+
const typeB = detectMemoryType(b);
|
|
145
|
+
const scoreA = typeA === "knowledge" || typeA === "project" ? 1 : 0;
|
|
146
|
+
const scoreB = typeB === "knowledge" || typeB === "project" ? 1 : 0;
|
|
147
|
+
return scoreB - scoreA;
|
|
148
|
+
});
|
|
149
|
+
// 4. Cap at 3
|
|
150
|
+
const toCapture = filtered.slice(0, 3);
|
|
151
|
+
const channel = cfg.defaultChannel;
|
|
152
|
+
// 5. Create memories
|
|
153
|
+
for (const text of toCapture) {
|
|
154
|
+
const { params } = createMemoryParams(text, cfg, channel, guard);
|
|
155
|
+
await client.createMemory(params);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
logger.warn(`compaction-hook error: ${err instanceof Error ? err.message : "unknown"}`);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ContextEngineProvider } from "../plugin-sdk-stub.js";
|
|
2
|
+
import type { LanonasisClient } from "../client.js";
|
|
3
|
+
import type { LanonasisConfig } from "../config.js";
|
|
4
|
+
export declare function createContextEngine(client: LanonasisClient, cfg: LanonasisConfig): ContextEngineProvider;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// RecallForge — contextEngine slot implementation
|
|
2
|
+
// OpenClaw calls buildContext() on demand (not just at session start).
|
|
3
|
+
// This fills plugins.slots.contextEngine, the slot LinkMind currently occupies.
|
|
4
|
+
// Reuses the tieredSearch + prompt-safety machinery from recall.ts.
|
|
5
|
+
import { tieredSearch } from "./recall.js";
|
|
6
|
+
import { looksLikePromptInjection, formatRecalledMemories, } from "../enrichment/prompt-safety.js";
|
|
7
|
+
export function createContextEngine(client, cfg) {
|
|
8
|
+
return {
|
|
9
|
+
id: "recall-forge",
|
|
10
|
+
// Priority 10 — runs before lower-priority engines, yields to explicit user context
|
|
11
|
+
priority: 10,
|
|
12
|
+
async buildContext(session) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = (session.currentInput ?? session.query ?? "").trim();
|
|
15
|
+
if (!raw || raw.length < 5)
|
|
16
|
+
return "";
|
|
17
|
+
// Normalise query: strip punctuation-only lines, cap at 200 chars
|
|
18
|
+
const query = raw
|
|
19
|
+
.split("\n")
|
|
20
|
+
.filter((l) => /\w/.test(l))
|
|
21
|
+
.join(" ")
|
|
22
|
+
.slice(0, 200)
|
|
23
|
+
.trim() || raw.slice(0, 200);
|
|
24
|
+
// 5s timeout — never block the agent
|
|
25
|
+
const memories = await Promise.race([
|
|
26
|
+
tieredSearch(client, cfg, query),
|
|
27
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("recall-forge context timeout")), 5000)),
|
|
28
|
+
]);
|
|
29
|
+
if (!memories || memories.length === 0)
|
|
30
|
+
return "";
|
|
31
|
+
// Filter prompt injection attempts before injecting into context window
|
|
32
|
+
const filtered = memories.filter((m) => !looksLikePromptInjection(m.content));
|
|
33
|
+
if (filtered.length === 0)
|
|
34
|
+
return "";
|
|
35
|
+
const entries = filtered.map((m) => ({
|
|
36
|
+
title: m.title,
|
|
37
|
+
type: m.type,
|
|
38
|
+
content: m.content,
|
|
39
|
+
similarity: m.similarity,
|
|
40
|
+
id: m.id,
|
|
41
|
+
tags: m.tags,
|
|
42
|
+
}));
|
|
43
|
+
return formatRecalledMemories(entries, {
|
|
44
|
+
recallStrategy: "semantic",
|
|
45
|
+
maxChars: cfg.maxRecallChars,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Never throw — a failed context engine must not break the agent session
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Phase 4 - Local Fallback
|
|
2
|
+
// Note: content passed to writeMemory() should already be sanitized by PrivacyGuard.
|
|
3
|
+
// redactSecrets() runs here as a last-resort belt-and-suspenders defense.
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { redactSecrets } from "../extraction/secret-redactor.js";
|
|
7
|
+
export class LocalFallbackWriter {
|
|
8
|
+
resolvePath;
|
|
9
|
+
constructor(resolvePath) {
|
|
10
|
+
this.resolvePath = resolvePath;
|
|
11
|
+
}
|
|
12
|
+
async writeMemory(title, content) {
|
|
13
|
+
try {
|
|
14
|
+
// Last-resort: strip any credentials that slipped past the guard
|
|
15
|
+
const { text: safeContent } = redactSecrets(content);
|
|
16
|
+
// Target: <workspace>/memory/YYYY-MM-DD.md
|
|
17
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
18
|
+
const filePath = this.resolvePath(join("memory", `${today}.md`));
|
|
19
|
+
// Ensure directory exists
|
|
20
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
21
|
+
await fs.mkdir(dir, { recursive: true });
|
|
22
|
+
// Format: "## {title}\n\n{content.slice(0, 1000)}\n\n---\n"
|
|
23
|
+
const formatted = `## ${title}\n\n${safeContent.slice(0, 1000)}\n\n---\n`;
|
|
24
|
+
// Append mode
|
|
25
|
+
await fs.appendFile(filePath, formatted, "utf-8");
|
|
26
|
+
}
|
|
27
|
+
catch (_err) {
|
|
28
|
+
// Silently catch all errors - never throws
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LanonasisClient, LanMemory } from "../client.js";
|
|
2
|
+
import type { LanonasisConfig } from "../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Phase 2: Tiered recall with explicit fallback order.
|
|
5
|
+
*
|
|
6
|
+
* 1. Personal — search scoped to this agent's agentId
|
|
7
|
+
* 2. Shared — search scoped to sharedNamespace (if configured)
|
|
8
|
+
* 3. Deduplicate across tiers (by memory id)
|
|
9
|
+
* 4. Cap to maxRecallResults
|
|
10
|
+
*
|
|
11
|
+
* If sharedNamespace is empty, recall behaves as before (single unscoped search).
|
|
12
|
+
*/
|
|
13
|
+
export declare function tieredSearch(client: LanonasisClient, cfg: LanonasisConfig, query: string): Promise<(LanMemory & {
|
|
14
|
+
_recallSource: string;
|
|
15
|
+
})[]>;
|
|
16
|
+
export declare function createRecallHook(client: LanonasisClient, cfg: LanonasisConfig): (event: {
|
|
17
|
+
prompt: string;
|
|
18
|
+
messages?: unknown[];
|
|
19
|
+
}) => Promise<{
|
|
20
|
+
prependContext?: string;
|
|
21
|
+
} | void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { looksLikePromptInjection, formatRecalledMemories } from "../enrichment/prompt-safety.js";
|
|
2
|
+
/**
|
|
3
|
+
* Check if a memory's embedding profile matches the current config.
|
|
4
|
+
* Returns a warning string if mismatched, or undefined if OK.
|
|
5
|
+
*/
|
|
6
|
+
function detectProfileMismatch(memory, cfg) {
|
|
7
|
+
if (!cfg.embeddingProfileId)
|
|
8
|
+
return undefined;
|
|
9
|
+
const storedProfile = memory.metadata?.embedding_profile_id;
|
|
10
|
+
if (!storedProfile)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (storedProfile === cfg.embeddingProfileId)
|
|
13
|
+
return undefined;
|
|
14
|
+
return `⚠ profile mismatch: stored=${storedProfile}, query=${cfg.embeddingProfileId}`;
|
|
15
|
+
}
|
|
16
|
+
/** Tag a recalled memory with its source layer for display */
|
|
17
|
+
function tagSource(m, source) {
|
|
18
|
+
return { ...m, _recallSource: source };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Phase 2: Tiered recall with explicit fallback order.
|
|
22
|
+
*
|
|
23
|
+
* 1. Personal — search scoped to this agent's agentId
|
|
24
|
+
* 2. Shared — search scoped to sharedNamespace (if configured)
|
|
25
|
+
* 3. Deduplicate across tiers (by memory id)
|
|
26
|
+
* 4. Cap to maxRecallResults
|
|
27
|
+
*
|
|
28
|
+
* If sharedNamespace is empty, recall behaves as before (single unscoped search).
|
|
29
|
+
*/
|
|
30
|
+
export async function tieredSearch(client, cfg, query) {
|
|
31
|
+
const baseParams = {
|
|
32
|
+
query,
|
|
33
|
+
threshold: cfg.searchThreshold,
|
|
34
|
+
limit: cfg.maxRecallResults,
|
|
35
|
+
};
|
|
36
|
+
// If no shared namespace is configured, do a single unscoped search (backwards compat)
|
|
37
|
+
if (!cfg.sharedNamespace) {
|
|
38
|
+
const results = await client.searchMemories(baseParams);
|
|
39
|
+
return (results ?? []).map((m) => tagSource(m, "personal"));
|
|
40
|
+
}
|
|
41
|
+
// Tier 1: Personal recall — scoped to this agent
|
|
42
|
+
const personalResults = await client.searchMemories({
|
|
43
|
+
...baseParams,
|
|
44
|
+
metadata: { agent_id: cfg.agentId },
|
|
45
|
+
});
|
|
46
|
+
// Tier 2: Shared recall — scoped to shared namespace
|
|
47
|
+
const sharedResults = await client.searchMemories({
|
|
48
|
+
...baseParams,
|
|
49
|
+
metadata: { namespace: cfg.sharedNamespace },
|
|
50
|
+
});
|
|
51
|
+
// Deduplicate by id (personal takes priority)
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const merged = [];
|
|
54
|
+
for (const m of personalResults ?? []) {
|
|
55
|
+
if (!seen.has(m.id)) {
|
|
56
|
+
seen.add(m.id);
|
|
57
|
+
merged.push(tagSource(m, "personal"));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const m of sharedResults ?? []) {
|
|
61
|
+
if (!seen.has(m.id)) {
|
|
62
|
+
seen.add(m.id);
|
|
63
|
+
merged.push(tagSource(m, "shared"));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Sort by similarity descending, cap to limit
|
|
67
|
+
merged.sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0));
|
|
68
|
+
return merged.slice(0, cfg.maxRecallResults);
|
|
69
|
+
}
|
|
70
|
+
export function createRecallHook(client, cfg) {
|
|
71
|
+
return async (event) => {
|
|
72
|
+
try {
|
|
73
|
+
// 1. Skip if prompt too short
|
|
74
|
+
if (!event.prompt || event.prompt.length < 5) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
// 2. Trim query: first 200 chars, drop punctuation-only lines
|
|
78
|
+
const trimmedQuery = event.prompt
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((l) => /\w/.test(l))
|
|
81
|
+
.join(" ")
|
|
82
|
+
.slice(0, 200)
|
|
83
|
+
.trim() || event.prompt.slice(0, 200);
|
|
84
|
+
// 3. Tiered search: personal → shared (5s timeout)
|
|
85
|
+
const memories = await Promise.race([
|
|
86
|
+
tieredSearch(client, cfg, trimmedQuery),
|
|
87
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("recall timeout")), 5000)),
|
|
88
|
+
]);
|
|
89
|
+
// 4. If 0 results, return void
|
|
90
|
+
if (!memories || memories.length === 0) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
// 5. Filter out prompt injection attempts
|
|
94
|
+
const filtered = memories.filter((m) => !looksLikePromptInjection(m.content));
|
|
95
|
+
if (filtered.length === 0) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
// 6. Annotate with profile mismatch and source, pass id + tags through
|
|
99
|
+
const entries = filtered.map((m) => {
|
|
100
|
+
const mismatch = detectProfileMismatch(m, cfg);
|
|
101
|
+
const sourceTag = cfg.sharedNamespace ? ` [${m._recallSource}]` : "";
|
|
102
|
+
return {
|
|
103
|
+
title: `${m.title}${sourceTag}`,
|
|
104
|
+
type: m.type,
|
|
105
|
+
content: mismatch ? `${m.content}\n\n${mismatch}` : m.content,
|
|
106
|
+
similarity: m.similarity,
|
|
107
|
+
id: m.id,
|
|
108
|
+
tags: m.tags,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
// 7. Format and return with strategy hint and char budget
|
|
112
|
+
const formatted = formatRecalledMemories(entries, {
|
|
113
|
+
recallStrategy: "semantic",
|
|
114
|
+
maxChars: cfg.maxRecallChars,
|
|
115
|
+
});
|
|
116
|
+
return { prependContext: formatted };
|
|
117
|
+
}
|
|
118
|
+
catch (_err) {
|
|
119
|
+
// Never throw - return void on error
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { lanonasisConfigSchema } from "./config.js";
|
|
2
|
+
import { LanonasisClient } from "./client.js";
|
|
3
|
+
import { LocalFallbackWriter } from "./hooks/local-fallback.js";
|
|
4
|
+
import { createContextEngine } from "./hooks/context-engine.js";
|
|
5
|
+
import { createCaptureHook, createCompactionCaptureHook } from "./hooks/capture.js";
|
|
6
|
+
import { registerMemorySearchTool } from "./tools/memory-search.js";
|
|
7
|
+
import { registerMemoryGetTool } from "./tools/memory-get.js";
|
|
8
|
+
import { registerMemoryStoreTool } from "./tools/memory-store.js";
|
|
9
|
+
import { registerMemoryForgetTool } from "./tools/memory-forget.js";
|
|
10
|
+
import { registerCli } from "./cli.js";
|
|
11
|
+
import { PrivacyGuard } from "./privacy/privacy-guard.js";
|
|
12
|
+
import { PrivacyLogWriter } from "./privacy/privacy-log.js";
|
|
13
|
+
const CONFIG_PATH = 'plugins.entries["recall-forge"].config';
|
|
14
|
+
function formatErrorMessage(error) {
|
|
15
|
+
if (error instanceof Error && error.message.trim()) {
|
|
16
|
+
return error.message;
|
|
17
|
+
}
|
|
18
|
+
return typeof error === "string" && error.trim() ? error : "unknown error";
|
|
19
|
+
}
|
|
20
|
+
function missingConfigMessage(field, envName) {
|
|
21
|
+
return `${field} is required. Set ${CONFIG_PATH}.${field} in ~/.openclaw/openclaw.json or export ${envName} before starting OpenClaw.`;
|
|
22
|
+
}
|
|
23
|
+
const plugin = {
|
|
24
|
+
id: "recall-forge",
|
|
25
|
+
kind: "memory",
|
|
26
|
+
name: "RecallForge",
|
|
27
|
+
description: "Secret-safe memory and context engine for OpenClaw — semantic recall with credential protection",
|
|
28
|
+
configSchema: {}, // JSON Schema validation handled by openclaw.plugin.json
|
|
29
|
+
register(api) {
|
|
30
|
+
let cachedRuntime;
|
|
31
|
+
const getRuntime = () => {
|
|
32
|
+
const cfg = lanonasisConfigSchema.parse(api.pluginConfig);
|
|
33
|
+
if (!cfg.apiKey) {
|
|
34
|
+
throw new Error(missingConfigMessage("apiKey", "LANONASIS_API_KEY"));
|
|
35
|
+
}
|
|
36
|
+
if (!cfg.projectId) {
|
|
37
|
+
throw new Error(missingConfigMessage("projectId", "LANONASIS_PROJECT_ID"));
|
|
38
|
+
}
|
|
39
|
+
const cacheKey = JSON.stringify([
|
|
40
|
+
cfg.baseUrl,
|
|
41
|
+
cfg.apiKey,
|
|
42
|
+
cfg.projectId,
|
|
43
|
+
cfg.agentId,
|
|
44
|
+
cfg.autoRecall,
|
|
45
|
+
cfg.recallMode,
|
|
46
|
+
cfg.maxRecallChars,
|
|
47
|
+
cfg.captureMode,
|
|
48
|
+
cfg.localFallback,
|
|
49
|
+
cfg.searchThreshold,
|
|
50
|
+
cfg.dedupeThreshold,
|
|
51
|
+
cfg.maxRecallResults,
|
|
52
|
+
cfg.memoryMode,
|
|
53
|
+
cfg.sharedNamespace,
|
|
54
|
+
cfg.embeddingProfileId,
|
|
55
|
+
]);
|
|
56
|
+
if (!cachedRuntime || cachedRuntime.cacheKey !== cacheKey) {
|
|
57
|
+
cachedRuntime = {
|
|
58
|
+
cfg,
|
|
59
|
+
client: new LanonasisClient(cfg),
|
|
60
|
+
cacheKey,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return cachedRuntime;
|
|
64
|
+
};
|
|
65
|
+
// 1. Always register CLI so subcommands remain visible even if runtime config is incomplete.
|
|
66
|
+
registerCli(api, getRuntime);
|
|
67
|
+
// 2. Initialise runtime for hooks and tools.
|
|
68
|
+
let runtime;
|
|
69
|
+
try {
|
|
70
|
+
runtime = getRuntime();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
api.logger.error(`[recall-forge] ${formatErrorMessage(err)}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { client, cfg } = runtime;
|
|
77
|
+
// 3. Local fallback writer (writes ~/.openclaw/workspace/memory/YYYY-MM-DD.md)
|
|
78
|
+
const fallback = new LocalFallbackWriter(api.resolvePath);
|
|
79
|
+
// 3a. Privacy guard — two-stage pipeline: credential stripping + PII masking
|
|
80
|
+
// privacyMode: 'mask' (default) | 'detect' (scan only) | 'off' (credentials only)
|
|
81
|
+
// logger passed so webhook failures are surfaced as warnings rather than silently dropped
|
|
82
|
+
const guard = new PrivacyGuard(cfg, api.logger);
|
|
83
|
+
const privacyLog = cfg.localFallback ? new PrivacyLogWriter(api.resolvePath) : undefined;
|
|
84
|
+
// 4. Context engine — fills plugins.slots.contextEngine (active, on-demand)
|
|
85
|
+
// OpenClaw calls buildContext() whenever it needs to assemble agent context.
|
|
86
|
+
// Secret-redacted tiered recall with prompt injection protection.
|
|
87
|
+
api.registerContextEngine(createContextEngine(client, cfg));
|
|
88
|
+
// 5. Capture hooks — auto/hybrid modes only (explicit = agent calls memory_store directly)
|
|
89
|
+
if (cfg.captureMode !== "explicit") {
|
|
90
|
+
api.on("agent_end", createCaptureHook(client, cfg, api.logger, fallback, guard, privacyLog));
|
|
91
|
+
api.on("before_compaction", createCompactionCaptureHook(client, cfg, api.logger, guard));
|
|
92
|
+
}
|
|
93
|
+
// 6. Agent tools — always registered regardless of captureMode
|
|
94
|
+
registerMemorySearchTool(api, client, cfg);
|
|
95
|
+
registerMemoryGetTool(api, client);
|
|
96
|
+
registerMemoryStoreTool(api, client, cfg, guard);
|
|
97
|
+
registerMemoryForgetTool(api, client);
|
|
98
|
+
const sharedLabel = cfg.sharedNamespace ? `shared: ${cfg.sharedNamespace}` : "shared: off";
|
|
99
|
+
const recallStatus = cfg.autoRecall && cfg.recallMode !== "ondemand" ? "contextEngine" : cfg.recallMode === "ondemand" ? "ondemand" : "off";
|
|
100
|
+
api.logger.info(`[recall-forge] Ready — slots: memory+contextEngine | mode: ${cfg.captureMode} | memory: ${cfg.memoryMode} | recall: ${recallStatus} | ${sharedLabel} | fallback: ${cfg.localFallback} | privacy: ${cfg.privacyMode} | project: ${cfg.projectId}`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
export default plugin;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session object passed to contextEngine.buildContext().
|
|
3
|
+
* OpenClaw populates this with the current agent session state.
|
|
4
|
+
*/
|
|
5
|
+
export type OpenClawSession = {
|
|
6
|
+
/** The user's current input or prompt being assembled */
|
|
7
|
+
currentInput?: string;
|
|
8
|
+
/** Alias for currentInput — some OpenClaw versions use this field */
|
|
9
|
+
query?: string;
|
|
10
|
+
/** Prior turns in the current session */
|
|
11
|
+
history?: unknown[];
|
|
12
|
+
/** Arbitrary session metadata provided by OpenClaw */
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A context engine provider — registered via api.registerContextEngine().
|
|
17
|
+
* OpenClaw calls buildContext() on demand to inject context into the prompt window.
|
|
18
|
+
* This is a separate slot from memory (plugins.slots.contextEngine).
|
|
19
|
+
*/
|
|
20
|
+
export type ContextEngineProvider = {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Higher priority runs first when multiple contextEngines are registered */
|
|
23
|
+
priority?: number;
|
|
24
|
+
/** Return a formatted string to prepend to the agent context window */
|
|
25
|
+
buildContext: (session: OpenClawSession) => Promise<string>;
|
|
26
|
+
};
|
|
27
|
+
export type OpenClawPluginApi = {
|
|
28
|
+
pluginConfig: unknown;
|
|
29
|
+
resolvePath: (path: string) => string;
|
|
30
|
+
logger: {
|
|
31
|
+
info: (msg: string) => void;
|
|
32
|
+
warn: (msg: string) => void;
|
|
33
|
+
error: (msg: string) => void;
|
|
34
|
+
};
|
|
35
|
+
on: (event: string, handler: Function) => void;
|
|
36
|
+
registerTool: (tool: any, opts?: any) => void;
|
|
37
|
+
registerCli: (handler: Function, opts: any) => void;
|
|
38
|
+
registerService: (service: any) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Register a context engine provider.
|
|
41
|
+
* Fills the plugins.slots.contextEngine slot in OpenClaw.
|
|
42
|
+
* OpenClaw calls buildContext() whenever it needs to assemble agent context.
|
|
43
|
+
*/
|
|
44
|
+
registerContextEngine: (engine: ContextEngineProvider) => void;
|
|
45
|
+
};
|
|
46
|
+
export type OpenClawPlugin = {
|
|
47
|
+
id: string;
|
|
48
|
+
kind: string;
|
|
49
|
+
name?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
configSchema?: any;
|
|
52
|
+
register: (api: OpenClawPluginApi) => void;
|
|
53
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LanonasisConfig } from "../config.js";
|
|
2
|
+
export type PrivacyMode = "off" | "detect" | "mask";
|
|
3
|
+
export interface PrivacyReport {
|
|
4
|
+
secretsFound: number;
|
|
5
|
+
secretTypes: string[];
|
|
6
|
+
piiFound: boolean;
|
|
7
|
+
piiTypes: string[];
|
|
8
|
+
piiSensitivity: "none" | "low" | "medium" | "high" | "critical";
|
|
9
|
+
regulations: string[];
|
|
10
|
+
action: "passthrough" | "redacted" | "masked" | "detected" | "redacted+masked";
|
|
11
|
+
timestamp: string;
|
|
12
|
+
}
|
|
13
|
+
export interface GuardResult {
|
|
14
|
+
content: string;
|
|
15
|
+
report: PrivacyReport;
|
|
16
|
+
}
|
|
17
|
+
export declare class PrivacyGuard {
|
|
18
|
+
private sdk;
|
|
19
|
+
private mode;
|
|
20
|
+
private locale;
|
|
21
|
+
private notifyUrl;
|
|
22
|
+
private logger?;
|
|
23
|
+
constructor(cfg: LanonasisConfig, logger?: {
|
|
24
|
+
warn(msg: string): void;
|
|
25
|
+
});
|
|
26
|
+
process(content: string): GuardResult;
|
|
27
|
+
/** Tags to merge into memory tags based on what was found */
|
|
28
|
+
tagsFrom(report: PrivacyReport): string[];
|
|
29
|
+
/** Metadata to merge into memory.metadata — omitted entirely if passthrough */
|
|
30
|
+
metaFrom(report: PrivacyReport): Record<string, unknown> | undefined;
|
|
31
|
+
/** Fire-and-forget webhook — never blocks the write path, never throws */
|
|
32
|
+
private notify;
|
|
33
|
+
}
|