clawmem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +660 -0
  2. package/CLAUDE.md +660 -0
  3. package/LICENSE +21 -0
  4. package/README.md +993 -0
  5. package/SKILL.md +717 -0
  6. package/bin/clawmem +75 -0
  7. package/package.json +72 -0
  8. package/src/amem.ts +797 -0
  9. package/src/beads.ts +263 -0
  10. package/src/clawmem.ts +1849 -0
  11. package/src/collections.ts +405 -0
  12. package/src/config.ts +178 -0
  13. package/src/consolidation.ts +123 -0
  14. package/src/directory-context.ts +248 -0
  15. package/src/errors.ts +41 -0
  16. package/src/formatter.ts +427 -0
  17. package/src/graph-traversal.ts +247 -0
  18. package/src/hooks/context-surfacing.ts +317 -0
  19. package/src/hooks/curator-nudge.ts +89 -0
  20. package/src/hooks/decision-extractor.ts +639 -0
  21. package/src/hooks/feedback-loop.ts +214 -0
  22. package/src/hooks/handoff-generator.ts +345 -0
  23. package/src/hooks/postcompact-inject.ts +226 -0
  24. package/src/hooks/precompact-extract.ts +314 -0
  25. package/src/hooks/pretool-inject.ts +79 -0
  26. package/src/hooks/session-bootstrap.ts +324 -0
  27. package/src/hooks/staleness-check.ts +130 -0
  28. package/src/hooks.ts +367 -0
  29. package/src/indexer.ts +327 -0
  30. package/src/intent.ts +294 -0
  31. package/src/limits.ts +26 -0
  32. package/src/llm.ts +1175 -0
  33. package/src/mcp.ts +2138 -0
  34. package/src/memory.ts +336 -0
  35. package/src/mmr.ts +93 -0
  36. package/src/observer.ts +269 -0
  37. package/src/openclaw/engine.ts +283 -0
  38. package/src/openclaw/index.ts +221 -0
  39. package/src/openclaw/plugin.json +83 -0
  40. package/src/openclaw/shell.ts +207 -0
  41. package/src/openclaw/tools.ts +304 -0
  42. package/src/profile.ts +346 -0
  43. package/src/promptguard.ts +218 -0
  44. package/src/retrieval-gate.ts +106 -0
  45. package/src/search-utils.ts +127 -0
  46. package/src/server.ts +783 -0
  47. package/src/splitter.ts +325 -0
  48. package/src/store.ts +4062 -0
  49. package/src/validation.ts +67 -0
  50. package/src/watcher.ts +58 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ClawMem OpenClaw Plugin — Shell-out utilities
3
+ *
4
+ * Phase 1 transport: spawn `clawmem hook <name>` as a Bun subprocess.
5
+ * All hook handlers accept JSON on stdin and return JSON on stdout.
6
+ */
7
+
8
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ export type ClawMemConfig = {
17
+ clawmemBin: string;
18
+ tokenBudget: number;
19
+ profile: string;
20
+ enableTools: boolean;
21
+ servePort: number;
22
+ env: Record<string, string>;
23
+ };
24
+
25
+ export type ShellResult = {
26
+ stdout: string;
27
+ stderr: string;
28
+ exitCode: number;
29
+ };
30
+
31
+ // =============================================================================
32
+ // Binary Resolution
33
+ // =============================================================================
34
+
35
+ const SEARCH_PATHS = [
36
+ // Relative to this plugin (ClawMem repo layout)
37
+ resolve(__dirname, "../../bin/clawmem"),
38
+ // Common install locations
39
+ "/usr/local/bin/clawmem",
40
+ resolve(process.env.HOME || "/tmp", "Projects/forge-stack/skill-forge/clawmem/bin/clawmem"),
41
+ resolve(process.env.HOME || "/tmp", "clawmem/bin/clawmem"),
42
+ ];
43
+
44
+ export function resolveClawMemBin(configured?: string): string {
45
+ if (configured && existsSync(configured)) return configured;
46
+
47
+ for (const p of SEARCH_PATHS) {
48
+ if (existsSync(p)) return p;
49
+ }
50
+
51
+ // Fallback: assume it's on PATH
52
+ return "clawmem";
53
+ }
54
+
55
+ // =============================================================================
56
+ // Shell Execution
57
+ // =============================================================================
58
+
59
+ const DEFAULT_TIMEOUT = 10_000; // 10s for most hooks
60
+ const EXTRACTION_TIMEOUT = 30_000; // 30s for LLM-based extraction
61
+
62
+ /**
63
+ * Execute a clawmem hook with JSON on stdin, capture JSON stdout.
64
+ * Fail-open: returns empty result on timeout or error.
65
+ */
66
+ export function execHook(
67
+ cfg: ClawMemConfig,
68
+ hookName: string,
69
+ input: Record<string, unknown>,
70
+ timeout?: number
71
+ ): Promise<ShellResult> {
72
+ const hookTimeout = timeout ?? (
73
+ hookName === "decision-extractor" || hookName === "handoff-generator"
74
+ ? EXTRACTION_TIMEOUT
75
+ : DEFAULT_TIMEOUT
76
+ );
77
+
78
+ return new Promise((resolve) => {
79
+ const child = execFile(
80
+ cfg.clawmemBin,
81
+ ["hook", hookName],
82
+ {
83
+ timeout: hookTimeout,
84
+ env: { ...process.env, ...cfg.env },
85
+ maxBuffer: 1024 * 1024, // 1MB
86
+ },
87
+ (error, stdout, stderr) => {
88
+ if (error) {
89
+ // Fail-open: log but don't throw
90
+ const msg = (error as any).killed
91
+ ? `timeout after ${hookTimeout}ms`
92
+ : String(error.message || error);
93
+ resolve({
94
+ stdout: "",
95
+ stderr: `[clawmem-plugin] hook ${hookName} failed: ${msg}\n${stderr}`,
96
+ exitCode: (error as any).code ?? 1,
97
+ });
98
+ return;
99
+ }
100
+ resolve({ stdout: stdout || "", stderr: stderr || "", exitCode: 0 });
101
+ }
102
+ );
103
+
104
+ // Send hook input on stdin
105
+ if (child.stdin) {
106
+ child.stdin.write(JSON.stringify(input));
107
+ child.stdin.end();
108
+ }
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Execute a clawmem CLI command (non-hook).
114
+ */
115
+ export function execCommand(
116
+ cfg: ClawMemConfig,
117
+ args: string[],
118
+ timeout: number = DEFAULT_TIMEOUT
119
+ ): Promise<ShellResult> {
120
+ return new Promise((resolve) => {
121
+ execFile(
122
+ cfg.clawmemBin,
123
+ args,
124
+ {
125
+ timeout,
126
+ env: { ...process.env, ...cfg.env },
127
+ maxBuffer: 1024 * 1024,
128
+ },
129
+ (error, stdout, stderr) => {
130
+ if (error) {
131
+ resolve({
132
+ stdout: "",
133
+ stderr: `[clawmem-plugin] command failed: ${String(error.message || error)}\n${stderr}`,
134
+ exitCode: (error as any).code ?? 1,
135
+ });
136
+ return;
137
+ }
138
+ resolve({ stdout: stdout || "", stderr: stderr || "", exitCode: 0 });
139
+ }
140
+ );
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Spawn a long-lived background process (e.g., `clawmem serve`).
146
+ * Returns the child process handle for lifecycle management.
147
+ * The child is detached from the parent's event loop via unref().
148
+ */
149
+ export function spawnBackground(
150
+ cfg: ClawMemConfig,
151
+ args: string[],
152
+ logger?: { info: (...args: any[]) => void; warn: (...args: any[]) => void }
153
+ ): ChildProcess {
154
+ const child = spawn(cfg.clawmemBin, args, {
155
+ env: { ...process.env, ...cfg.env },
156
+ stdio: ["ignore", "pipe", "pipe"],
157
+ detached: false,
158
+ });
159
+
160
+ child.stdout?.on("data", (data: Buffer) => {
161
+ logger?.info(`[clawmem-serve] ${data.toString().trim()}`);
162
+ });
163
+
164
+ child.stderr?.on("data", (data: Buffer) => {
165
+ logger?.warn(`[clawmem-serve] ${data.toString().trim()}`);
166
+ });
167
+
168
+ child.on("exit", (code, signal) => {
169
+ logger?.warn(`[clawmem-serve] exited (code=${code}, signal=${signal})`);
170
+ });
171
+
172
+ child.unref();
173
+ return child;
174
+ }
175
+
176
+ /**
177
+ * Parse hook output JSON. Returns null on parse failure.
178
+ */
179
+ export function parseHookOutput(stdout: string): Record<string, unknown> | null {
180
+ if (!stdout.trim()) return null;
181
+ try {
182
+ return JSON.parse(stdout.trim());
183
+ } catch {
184
+ // Hook output may have non-JSON preamble (stderr leak)
185
+ // Try to find the last JSON object
186
+ const lastBrace = stdout.lastIndexOf("}");
187
+ const firstBrace = stdout.indexOf("{");
188
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
189
+ try {
190
+ return JSON.parse(stdout.slice(firstBrace, lastBrace + 1));
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Extract additionalContext from hook output.
201
+ * Hooks return: { hookSpecificOutput: { additionalContext: "..." } }
202
+ */
203
+ export function extractContext(hookOutput: Record<string, unknown> | null): string {
204
+ if (!hookOutput) return "";
205
+ const hso = hookOutput.hookSpecificOutput as Record<string, unknown> | undefined;
206
+ return (hso?.additionalContext as string) || "";
207
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * ClawMem OpenClaw Plugin — Tool registrations
3
+ *
4
+ * Registers a subset of ClawMem's retrieval tools as OpenClaw agent tools.
5
+ * Tools call the ClawMem REST API (clawmem serve) for efficient access.
6
+ *
7
+ * Registered tools (retrieval subset per GPT 5.4 recommendation):
8
+ * - clawmem_search: Unified search (keyword/semantic/hybrid)
9
+ * - clawmem_get: Get document by docid
10
+ * - clawmem_session_log: Recent session summaries
11
+ * - clawmem_timeline: Temporal context around a document
12
+ * - clawmem_similar: Find similar documents
13
+ */
14
+
15
+ import type { ClawMemConfig } from "./shell.js";
16
+
17
+ // =============================================================================
18
+ // Types (matching OpenClaw's tool interface without importing it)
19
+ // =============================================================================
20
+
21
+ type ToolResult = {
22
+ content: Array<{ type: string; text: string }>;
23
+ details?: Record<string, unknown>;
24
+ };
25
+
26
+ type Logger = {
27
+ debug?: (msg: string) => void;
28
+ info: (msg: string) => void;
29
+ warn: (msg: string) => void;
30
+ error: (msg: string) => void;
31
+ };
32
+
33
+ // =============================================================================
34
+ // REST API Client
35
+ // =============================================================================
36
+
37
+ async function apiCall(
38
+ cfg: ClawMemConfig,
39
+ method: string,
40
+ path: string,
41
+ body?: Record<string, unknown>
42
+ ): Promise<{ ok: boolean; status: number; data: any }> {
43
+ const url = `http://127.0.0.1:${cfg.servePort}${path}`;
44
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
45
+
46
+ // Add auth token if configured
47
+ const token = cfg.env.CLAWMEM_API_TOKEN || process.env.CLAWMEM_API_TOKEN;
48
+ if (token) headers["Authorization"] = `Bearer ${token}`;
49
+
50
+ try {
51
+ const resp = await fetch(url, {
52
+ method,
53
+ headers,
54
+ body: body ? JSON.stringify(body) : undefined,
55
+ signal: AbortSignal.timeout(5000),
56
+ });
57
+ const data = await resp.json();
58
+ return { ok: resp.ok, status: resp.status, data };
59
+ } catch (err) {
60
+ return {
61
+ ok: false,
62
+ status: 0,
63
+ data: { error: `ClawMem API unreachable at ${url}: ${String(err)}` },
64
+ };
65
+ }
66
+ }
67
+
68
+ // =============================================================================
69
+ // Tool Definitions
70
+ // =============================================================================
71
+
72
+ type ToolDef = {
73
+ name: string;
74
+ label: string;
75
+ description: string;
76
+ parameters: Record<string, unknown>;
77
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
78
+ };
79
+
80
+ export function createTools(cfg: ClawMemConfig, logger: Logger): ToolDef[] {
81
+ return [
82
+ // --- Unified Search ---
83
+ {
84
+ name: "clawmem_search",
85
+ label: "ClawMem Search",
86
+ description:
87
+ "Search long-term memory for relevant context. Supports keyword, semantic, and hybrid modes. " +
88
+ "Use for recalling past decisions, preferences, session history, and learned patterns.",
89
+ parameters: {
90
+ type: "object",
91
+ properties: {
92
+ query: { type: "string", description: "Search query" },
93
+ mode: {
94
+ type: "string",
95
+ enum: ["auto", "keyword", "semantic", "hybrid"],
96
+ description: "Search mode (default: auto)",
97
+ },
98
+ collection: { type: "string", description: "Limit to specific collection" },
99
+ limit: { type: "number", description: "Max results (default: 10, max: 50)" },
100
+ compact: { type: "boolean", description: "Return compact results (default: true)" },
101
+ },
102
+ required: ["query"],
103
+ },
104
+ async execute(_id, params) {
105
+ const result = await apiCall(cfg, "POST", "/search", {
106
+ query: params.query as string,
107
+ mode: params.mode ?? "auto",
108
+ collection: params.collection,
109
+ limit: params.limit ?? 10,
110
+ compact: params.compact ?? true,
111
+ });
112
+
113
+ if (!result.ok) {
114
+ return {
115
+ content: [{ type: "text", text: `Search failed: ${result.data?.error || "unknown error"}` }],
116
+ };
117
+ }
118
+
119
+ const data = result.data;
120
+ if (!data.results || data.results.length === 0) {
121
+ return {
122
+ content: [{ type: "text", text: "No relevant memories found." }],
123
+ details: { count: 0 },
124
+ };
125
+ }
126
+
127
+ const text = data.results
128
+ .map((r: any, i: number) =>
129
+ `${i + 1}. [${r.contentType || "note"}] ${r.title || r.path} (score: ${r.score})${r.snippet ? `\n ${r.snippet}` : ""}`
130
+ )
131
+ .join("\n");
132
+
133
+ return {
134
+ content: [{ type: "text", text: `Found ${data.count} results:\n\n${text}` }],
135
+ details: { count: data.count, query: data.query, mode: data.mode },
136
+ };
137
+ },
138
+ },
139
+
140
+ // --- Get Document ---
141
+ {
142
+ name: "clawmem_get",
143
+ label: "ClawMem Get",
144
+ description:
145
+ "Retrieve full content of a specific memory document by its docid (6-char hex prefix).",
146
+ parameters: {
147
+ type: "object",
148
+ properties: {
149
+ docid: { type: "string", description: "Document ID (6-char hex prefix from search results)" },
150
+ },
151
+ required: ["docid"],
152
+ },
153
+ async execute(_id, params) {
154
+ const docid = params.docid as string;
155
+ const result = await apiCall(cfg, "GET", `/documents/${docid}`);
156
+
157
+ if (!result.ok) {
158
+ return {
159
+ content: [{ type: "text", text: `Document not found: ${docid}` }],
160
+ };
161
+ }
162
+
163
+ const d = result.data;
164
+ return {
165
+ content: [{
166
+ type: "text",
167
+ text: `# ${d.title || d.path}\n\nCollection: ${d.collection}\nModified: ${d.modifiedAt}\n\n${d.body}`,
168
+ }],
169
+ details: { docid: d.docid, path: d.path },
170
+ };
171
+ },
172
+ },
173
+
174
+ // --- Session Log ---
175
+ {
176
+ name: "clawmem_session_log",
177
+ label: "ClawMem Sessions",
178
+ description:
179
+ "List recent session summaries. Use when asked about past work, previous conversations, or session history.",
180
+ parameters: {
181
+ type: "object",
182
+ properties: {
183
+ limit: { type: "number", description: "Number of sessions to return (default: 5)" },
184
+ },
185
+ },
186
+ async execute(_id, params) {
187
+ const limit = (params.limit as number) || 5;
188
+ const result = await apiCall(cfg, "GET", `/sessions?limit=${limit}`);
189
+
190
+ if (!result.ok) {
191
+ return {
192
+ content: [{ type: "text", text: `Failed to retrieve sessions: ${result.data?.error}` }],
193
+ };
194
+ }
195
+
196
+ const sessions = result.data.sessions;
197
+ if (!sessions || sessions.length === 0) {
198
+ return { content: [{ type: "text", text: "No session history found." }] };
199
+ }
200
+
201
+ const text = sessions
202
+ .map((s: any, i: number) =>
203
+ `${i + 1}. [${s.started_at}] ${s.session_id?.slice(0, 8)}... — ${s.prompt_count || 0} prompts`
204
+ )
205
+ .join("\n");
206
+
207
+ return {
208
+ content: [{ type: "text", text: `Recent sessions:\n\n${text}` }],
209
+ details: { count: sessions.length },
210
+ };
211
+ },
212
+ },
213
+
214
+ // --- Timeline ---
215
+ {
216
+ name: "clawmem_timeline",
217
+ label: "ClawMem Timeline",
218
+ description:
219
+ "Show temporal context around a document — what was created before and after it.",
220
+ parameters: {
221
+ type: "object",
222
+ properties: {
223
+ docid: { type: "string", description: "Document ID (6-char hex prefix)" },
224
+ before: { type: "number", description: "Documents before (default: 5)" },
225
+ after: { type: "number", description: "Documents after (default: 5)" },
226
+ same_collection: { type: "boolean", description: "Limit to same collection (default: false)" },
227
+ },
228
+ required: ["docid"],
229
+ },
230
+ async execute(_id, params) {
231
+ const docid = params.docid as string;
232
+ const before = params.before ?? 5;
233
+ const after = params.after ?? 5;
234
+ const sameCol = params.same_collection ?? false;
235
+ const qs = `before=${before}&after=${after}&same_collection=${sameCol}`;
236
+ const result = await apiCall(cfg, "GET", `/timeline/${docid}?${qs}`);
237
+
238
+ if (!result.ok) {
239
+ return {
240
+ content: [{ type: "text", text: `Timeline failed: ${result.data?.error}` }],
241
+ };
242
+ }
243
+
244
+ const d = result.data;
245
+ const lines: string[] = [];
246
+ if (d.before?.length) {
247
+ lines.push("**Before:**");
248
+ for (const e of d.before) lines.push(` - [${e.modifiedAt}] ${e.title} (${e.collection})`);
249
+ }
250
+ lines.push(`**→ ${d.anchor?.title || docid}** [${d.anchor?.modifiedAt}]`);
251
+ if (d.after?.length) {
252
+ lines.push("**After:**");
253
+ for (const e of d.after) lines.push(` - [${e.modifiedAt}] ${e.title} (${e.collection})`);
254
+ }
255
+
256
+ return {
257
+ content: [{ type: "text", text: lines.join("\n") }],
258
+ details: { docid, before: d.before?.length, after: d.after?.length },
259
+ };
260
+ },
261
+ },
262
+
263
+ // --- Similar ---
264
+ {
265
+ name: "clawmem_similar",
266
+ label: "ClawMem Similar",
267
+ description:
268
+ "Find documents semantically similar to a given document. Use for discovery and context expansion.",
269
+ parameters: {
270
+ type: "object",
271
+ properties: {
272
+ docid: { type: "string", description: "Document ID (6-char hex prefix)" },
273
+ limit: { type: "number", description: "Max results (default: 5)" },
274
+ },
275
+ required: ["docid"],
276
+ },
277
+ async execute(_id, params) {
278
+ const docid = params.docid as string;
279
+ const limit = params.limit ?? 5;
280
+ const result = await apiCall(cfg, "GET", `/graph/similar/${docid}?limit=${limit}`);
281
+
282
+ if (!result.ok) {
283
+ return {
284
+ content: [{ type: "text", text: `Similar search failed: ${result.data?.error}` }],
285
+ };
286
+ }
287
+
288
+ const similar = result.data.similar;
289
+ if (!similar || similar.length === 0) {
290
+ return { content: [{ type: "text", text: "No similar documents found." }] };
291
+ }
292
+
293
+ const text = similar
294
+ .map((s: any, i: number) => `${i + 1}. ${s.title || s.path} (similarity: ${s.score})`)
295
+ .join("\n");
296
+
297
+ return {
298
+ content: [{ type: "text", text: `Similar documents:\n\n${text}` }],
299
+ details: { count: similar.length },
300
+ };
301
+ },
302
+ },
303
+ ];
304
+ }