@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.
Files changed (68) hide show
  1. package/.claw/skills/SKILL.md +347 -0
  2. package/CHANGELOG.md +162 -0
  3. package/LICENSE +21 -0
  4. package/README.md +302 -0
  5. package/SETUP.md +190 -0
  6. package/dist/cli-common.d.ts +25 -0
  7. package/dist/cli-common.js +338 -0
  8. package/dist/cli-memory.d.ts +6 -0
  9. package/dist/cli-memory.js +146 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.js +135 -0
  12. package/dist/client.d.ts +116 -0
  13. package/dist/client.js +643 -0
  14. package/dist/config.d.ts +41 -0
  15. package/dist/config.js +125 -0
  16. package/dist/enrichment/capture-filter.d.ts +4 -0
  17. package/dist/enrichment/capture-filter.js +44 -0
  18. package/dist/enrichment/prompt-safety.d.ts +13 -0
  19. package/dist/enrichment/prompt-safety.js +83 -0
  20. package/dist/enrichment/tag-extractor.d.ts +1 -0
  21. package/dist/enrichment/tag-extractor.js +47 -0
  22. package/dist/enrichment/type-detector.d.ts +2 -0
  23. package/dist/enrichment/type-detector.js +95 -0
  24. package/dist/extraction/cli-extract.d.ts +8 -0
  25. package/dist/extraction/cli-extract.js +66 -0
  26. package/dist/extraction/format-adapters.d.ts +8 -0
  27. package/dist/extraction/format-adapters.js +268 -0
  28. package/dist/extraction/index.d.ts +7 -0
  29. package/dist/extraction/index.js +7 -0
  30. package/dist/extraction/jsonl-extractor.d.ts +32 -0
  31. package/dist/extraction/jsonl-extractor.js +207 -0
  32. package/dist/extraction/markdown-extractor.d.ts +23 -0
  33. package/dist/extraction/markdown-extractor.js +228 -0
  34. package/dist/extraction/secret-redactor.d.ts +7 -0
  35. package/dist/extraction/secret-redactor.js +112 -0
  36. package/dist/extraction/sqlite-extractor.d.ts +15 -0
  37. package/dist/extraction/sqlite-extractor.js +245 -0
  38. package/dist/extraction/types.d.ts +50 -0
  39. package/dist/extraction/types.js +1 -0
  40. package/dist/hooks/capture.d.ts +23 -0
  41. package/dist/hooks/capture.js +162 -0
  42. package/dist/hooks/context-engine.d.ts +4 -0
  43. package/dist/hooks/context-engine.js +54 -0
  44. package/dist/hooks/local-fallback.d.ts +5 -0
  45. package/dist/hooks/local-fallback.js +31 -0
  46. package/dist/hooks/recall.d.ts +21 -0
  47. package/dist/hooks/recall.js +123 -0
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.js +103 -0
  50. package/dist/plugin-sdk-stub.d.ts +53 -0
  51. package/dist/plugin-sdk-stub.js +3 -0
  52. package/dist/privacy/privacy-guard.d.ts +33 -0
  53. package/dist/privacy/privacy-guard.js +130 -0
  54. package/dist/privacy/privacy-log.d.ts +6 -0
  55. package/dist/privacy/privacy-log.js +44 -0
  56. package/dist/tools/memory-forget.d.ts +3 -0
  57. package/dist/tools/memory-forget.js +109 -0
  58. package/dist/tools/memory-get.d.ts +3 -0
  59. package/dist/tools/memory-get.js +46 -0
  60. package/dist/tools/memory-search.d.ts +4 -0
  61. package/dist/tools/memory-search.js +95 -0
  62. package/dist/tools/memory-store.d.ts +5 -0
  63. package/dist/tools/memory-store.js +199 -0
  64. package/openclaw.plugin.json +315 -0
  65. package/package.json +90 -0
  66. package/setup/agents-memory.md +63 -0
  67. package/setup/heartbeat-memory.md +53 -0
  68. 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,5 @@
1
+ export declare class LocalFallbackWriter {
2
+ private resolvePath;
3
+ constructor(resolvePath: (p: string) => string);
4
+ writeMemory(title: string, content: string): Promise<void>;
5
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { OpenClawPlugin } from "./plugin-sdk-stub.js";
2
+ declare const plugin: OpenClawPlugin;
3
+ export default plugin;
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,3 @@
1
+ // Simple stub for OpenClaw plugin SDK
2
+ // This allows the plugin to compile and load
3
+ export {};
@@ -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
+ }