@akalsey/openclaw-memory 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.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # openclaw-memory
2
+
3
+ A per-turn-lean memory plugin. Small always-loaded core, explicit on-demand retrieval. Memory is markdown files on disk, search is BM25, and the corpus stays manageable through deliberate writes rather than passive capture.
4
+
5
+ ---
6
+
7
+ ## Setup
8
+
9
+ ### Install
10
+
11
+ ```bash
12
+ openclaw plugins install local:/path/to/openclaw-memory
13
+ ```
14
+
15
+ ### Configuration
16
+
17
+ ```json
18
+ {
19
+ "plugins": {
20
+ "memory": {
21
+ "memoryPath": "~/.openclaw/memory",
22
+ "search": {
23
+ "defaultLimit": 5,
24
+ "maxLimit": 20,
25
+ "recencyBoostDays": 30,
26
+ "recencyBoostMax": 1.2
27
+ },
28
+ "write": {
29
+ "requireTags": true,
30
+ "minTags": 1,
31
+ "maxTags": 12
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ All settings are optional — defaults above are used if omitted.
39
+
40
+ ### Storage layout
41
+
42
+ ```
43
+ ~/.openclaw/memory/
44
+ ├── indexed/
45
+ │ ├── 2026-05-20-posthog-billing-a3f9b2c1.md
46
+ │ └── ...
47
+ └── _searches.json
48
+ ```
49
+
50
+ Memories are plain markdown files. You can read, edit, and grep them in any editor. External edits are picked up automatically via filesystem watching.
51
+
52
+ ---
53
+
54
+ ## Memory entry format
55
+
56
+ ```markdown
57
+ ---
58
+ id: mem_2026-05-20_a3f9b2c1
59
+ created: 2026-05-20T14:30:00Z
60
+ updated: 2026-05-20T14:30:00Z
61
+ tags: [posthog, billing, group-identify]
62
+ source: session
63
+ score: 0.7
64
+ size_tier: full
65
+ last_accessed: 2026-05-20T14:30:00Z
66
+ access_count: 3
67
+ ---
68
+
69
+ # PostHog group identify billing investigation
70
+
71
+ PostHog charges separately for groupIdentify events under the data warehouse
72
+ SKU. Our May 2026 spike traced to a deploy that called groupIdentify on every
73
+ page view. Fix: call it once per session, cache client-side.
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Tools
79
+
80
+ | Tool | What it does |
81
+ |------|-------------|
82
+ | `memory_search(query, tags?, limit?)` | BM25 search, returns excerpts + metadata |
83
+ | `memory_get(id)` | Full content of one entry; updates access metadata |
84
+ | `memory_write(content, tags)` | Creates new entry, returns id |
85
+ | `memory_supersede(old_id, new_content, new_tags, reason)` | Replaces outdated entry |
86
+ | `memory_stats()` | Corpus stats: count, size, top tags, recent activity |
87
+ | `memory_recent_searches(limit?)` | Last N searches with result counts |
88
+
89
+ ---
90
+
91
+ ## Using it effectively
92
+
93
+ The agent learns when to search from the SKILL.md that ships with the plugin. Key behaviors to reinforce:
94
+
95
+ - When core memory has a pointer to indexed memory → agent searches before answering
96
+ - After an investigation produces findings → agent writes a memory entry
97
+ - When a memory contradicts current knowledge → agent supersedes rather than leaving conflicts
98
+
99
+ **Tags are the most important thing you control.** The agent writes them at creation time. Good tags are terms you'd type in a future search: domain names, topic words, project names. Sparse tags make recall worse; there's no downside to 8 tags if they're all relevant.
100
+
101
+ ---
102
+
103
+ ## Inspecting memory
104
+
105
+ ```bash
106
+ # How many entries, top tags
107
+ openclaw memory stats
108
+
109
+ # Recent searches and their result counts
110
+ openclaw memory recent-searches
111
+
112
+ # Browse entries directly
113
+ ls ~/.openclaw/memory/indexed/
114
+ grep -l "billing" ~/.openclaw/memory/indexed/
115
+ cat ~/.openclaw/memory/indexed/2026-05-20-posthog-billing-a3f9b2c1.md
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Troubleshooting
121
+
122
+ **Agent answers from training data instead of memory**
123
+ The agent didn't search. Check the SKILL.md is loading (visible in session start). Add explicit pointers in core memory: "Indexed memory has notes on [topic]." This gives the agent a concrete cue to search.
124
+
125
+ **Search returns nothing for a topic you know exists**
126
+ Try different query terms — BM25 is lexical, not semantic. If the entry uses different vocabulary than your query, it won't match. Check what terms are in the actual entry with `grep`. Add more tags when you write entries.
127
+
128
+ **Index out of sync after external edits**
129
+ The chokidar watcher picks up external file changes in real time. If it seems stale, restart the OpenClaw session to force a full reload.
130
+
131
+ **Corpus growing too large**
132
+ v1 has no automatic decay. Prune manually by deleting files from `~/.openclaw/memory/indexed/` or using `memory_supersede` to replace bulky entries with summaries. Semantic search + decay-as-pruning is planned for v2.
133
+
134
+ **Duplicate or contradictory entries**
135
+ Use `memory_supersede`. It deletes the old entry and creates a new one, appending the reason for traceability. Nothing in v1 detects contradictions automatically — the agent notices during retrieval and resolves them on encountering them.
package/SKILL.md ADDED
@@ -0,0 +1,44 @@
1
+ # Using openclaw-memory
2
+
3
+ You have two layers of memory:
4
+
5
+ **Core memory** — always loaded, broad strokes. Entries may include pointers like "Indexed memory has detailed notes on X." These pointers are your cue to search.
6
+
7
+ **Indexed memory** — searchable, detail-rich, not loaded by default. Six tools:
8
+
9
+ - `memory_search(query, tags?, limit?)` — BM25 search, returns excerpts
10
+ - `memory_get(id)` — load full content of one entry
11
+ - `memory_write(content, tags, title?, source?)` — create a new entry, returns id
12
+ - `memory_supersede(old_id, new_content, new_tags, reason)` — replace outdated entry
13
+ - `memory_stats()` — corpus statistics
14
+ - `memory_recent_searches(limit?)` — recent queries and their result counts
15
+
16
+ ## When to search
17
+
18
+ - Core memory has a pointer to indexed memory on this topic → search before answering
19
+ - Task requires procedural specifics, code, or prior investigation details → search first
20
+ - User asks "what do you know about X?" → search before falling back to training data
21
+ - You're about to do something and you know past sessions touched this domain → search for prior notes
22
+
23
+ ## When to write
24
+
25
+ - You learn something procedural or specific that will be useful in a future session
26
+ - An investigation produced findings that shouldn't be re-run from scratch
27
+ - A decision was made with non-obvious reasoning that future sessions should know
28
+ - A correction was made that reveals a domain-specific rule
29
+
30
+ **Tag generously.** Use terms you'd plausibly type in a future search. Domain names (posthog, github, salesforce), topic words (billing, funnel, merge, deploy), project names, people's names.
31
+
32
+ ## How to query
33
+
34
+ Write queries as natural phrases, not keyword lists. "PostHog billing spike investigation" works better than "posthog billing." Include terms from the likely entry body, not just the likely title.
35
+
36
+ ## The contradiction rule
37
+
38
+ If you retrieve a memory that contradicts a newer source or your current knowledge, use `memory_supersede` rather than leaving conflicting entries in the corpus. The reason field is logged; be specific about what changed and why.
39
+
40
+ ## When not to search
41
+
42
+ - Simple factual questions well-covered by training data
43
+ - Quick conversational exchanges
44
+ - Questions where core memory already gives you what you need
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/service.js";
@@ -0,0 +1,58 @@
1
+ const K1 = 1.5;
2
+ const B = 0.75;
3
+ export function tokenize(text) {
4
+ return text
5
+ .toLowerCase()
6
+ .replace(/[^\w\s]/g, " ")
7
+ .split(/\s+/)
8
+ .filter(t => t.length > 1);
9
+ }
10
+ export function buildCorpus(docs) {
11
+ const docMap = new Map();
12
+ const df = new Map();
13
+ let totalLength = 0;
14
+ for (const doc of docs) {
15
+ const tokens = tokenize(doc.text);
16
+ const tf = new Map();
17
+ for (const token of tokens)
18
+ tf.set(token, (tf.get(token) ?? 0) + 1);
19
+ docMap.set(doc.id, { tf, length: tokens.length });
20
+ totalLength += tokens.length;
21
+ for (const term of tf.keys())
22
+ df.set(term, (df.get(term) ?? 0) + 1);
23
+ }
24
+ return {
25
+ docs: docMap,
26
+ df,
27
+ N: docs.length,
28
+ avgdl: docs.length > 0 ? totalLength / docs.length : 0,
29
+ };
30
+ }
31
+ export function scoreDoc(corpus, docId, queryTokens) {
32
+ const doc = corpus.docs.get(docId);
33
+ if (!doc)
34
+ return 0;
35
+ let score = 0;
36
+ for (const term of queryTokens) {
37
+ const freq = doc.tf.get(term) ?? 0;
38
+ if (freq === 0)
39
+ continue;
40
+ const df = corpus.df.get(term) ?? 0;
41
+ const idf = Math.log((corpus.N - df + 0.5) / (df + 0.5) + 1);
42
+ const tfNorm = (freq * (K1 + 1)) / (freq + K1 * (1 - B + B * (doc.length / corpus.avgdl)));
43
+ score += idf * tfNorm;
44
+ }
45
+ return score;
46
+ }
47
+ export function search(corpus, query) {
48
+ const queryTokens = tokenize(query);
49
+ if (queryTokens.length === 0)
50
+ return [];
51
+ const results = [];
52
+ for (const id of corpus.docs.keys()) {
53
+ const score = scoreDoc(corpus, id, queryTokens);
54
+ if (score > 0)
55
+ results.push({ id, score });
56
+ }
57
+ return results.sort((a, b) => b.score - a.score);
58
+ }
@@ -0,0 +1,26 @@
1
+ import matter from "gray-matter";
2
+ import { extractTitle } from "./utils.js";
3
+ export function parseEntry(raw, filename) {
4
+ const parsed = matter(raw);
5
+ const fm = parsed.data;
6
+ const body = parsed.content.trim();
7
+ const slug = filename.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "");
8
+ return {
9
+ id: fm.id ?? "",
10
+ created: fm.created ?? new Date().toISOString(),
11
+ updated: fm.updated ?? new Date().toISOString(),
12
+ tags: Array.isArray(fm.tags) ? fm.tags : [],
13
+ source: (fm.source ?? "manual"),
14
+ score: fm.score ?? 0.5,
15
+ size_tier: (fm.size_tier ?? "full"),
16
+ last_accessed: fm.last_accessed ?? new Date().toISOString(),
17
+ access_count: fm.access_count ?? 0,
18
+ title: extractTitle(body, slug),
19
+ body,
20
+ filename,
21
+ };
22
+ }
23
+ export function serializeEntry(entry) {
24
+ const { title: _title, body, filename: _filename, ...fm } = entry;
25
+ return matter.stringify("\n" + body, fm);
26
+ }
@@ -0,0 +1,57 @@
1
+ import { loadAllEntries } from "./storage.js";
2
+ import { buildCorpus } from "./bm25.js";
3
+ import { searchEntries } from "./search.js";
4
+ export class IndexStore {
5
+ entries = new Map();
6
+ corpus = { docs: new Map(), df: new Map(), N: 0, avgdl: 0 };
7
+ async loadFromDirectory(indexedDir) {
8
+ const all = await loadAllEntries(indexedDir);
9
+ this.entries.clear();
10
+ for (const entry of all)
11
+ this.entries.set(entry.id, entry);
12
+ this.rebuildCorpus();
13
+ }
14
+ rebuildCorpus() {
15
+ const docs = [...this.entries.values()].map(e => ({
16
+ id: e.id,
17
+ text: `${e.title} ${e.title} ${e.tags.join(" ")} ${e.tags.join(" ")} ${e.body}`,
18
+ }));
19
+ this.corpus = buildCorpus(docs);
20
+ }
21
+ add(entry) {
22
+ this.entries.set(entry.id, entry);
23
+ this.rebuildCorpus();
24
+ }
25
+ update(entry) {
26
+ this.entries.set(entry.id, entry);
27
+ this.rebuildCorpus();
28
+ }
29
+ removeById(id) {
30
+ this.entries.delete(id);
31
+ this.rebuildCorpus();
32
+ }
33
+ removeByFilename(filename) {
34
+ for (const [id, entry] of this.entries) {
35
+ if (entry.filename === filename) {
36
+ this.entries.delete(id);
37
+ break;
38
+ }
39
+ }
40
+ this.rebuildCorpus();
41
+ }
42
+ getAll() {
43
+ return [...this.entries.values()];
44
+ }
45
+ getById(id) {
46
+ return this.entries.get(id);
47
+ }
48
+ getCorpus() {
49
+ return this.corpus;
50
+ }
51
+ size() {
52
+ return this.entries.size;
53
+ }
54
+ search(input, recencyBoostDays, recencyBoostMax, defaultLimit, maxLimit) {
55
+ return searchEntries(this.getAll(), this.corpus, input, recencyBoostDays, recencyBoostMax, defaultLimit, maxLimit);
56
+ }
57
+ }
@@ -0,0 +1,59 @@
1
+ import { tokenize, search as bm25Search } from "./bm25.js";
2
+ function extractExcerpt(body, queryTokens, maxLength = 200) {
3
+ if (body.length <= maxLength)
4
+ return body;
5
+ const lower = body.toLowerCase();
6
+ let bestPos = 0;
7
+ for (const token of queryTokens) {
8
+ const pos = lower.indexOf(token);
9
+ if (pos >= 0) {
10
+ bestPos = pos;
11
+ break;
12
+ }
13
+ }
14
+ const start = Math.max(0, bestPos - 60);
15
+ const end = Math.min(body.length, start + maxLength);
16
+ const excerpt = body.slice(start, end).trim();
17
+ return (start > 0 ? "…" : "") + excerpt + (end < body.length ? "…" : "");
18
+ }
19
+ function recencyBoost(createdAt, boostDays, maxBoost) {
20
+ const ageDays = (Date.now() - new Date(createdAt).getTime()) / 86_400_000;
21
+ if (ageDays >= boostDays)
22
+ return 1.0;
23
+ return 1.0 + (maxBoost - 1.0) * (1 - ageDays / boostDays);
24
+ }
25
+ function accessBoost(accessCount) {
26
+ return 1.0 + Math.min(0.1, accessCount * 0.01);
27
+ }
28
+ export function searchEntries(entries, corpus, input, recencyBoostDays, recencyBoostMax, defaultLimit, maxLimit) {
29
+ const limit = Math.min(input.limit ?? defaultLimit, maxLimit);
30
+ const tagFilter = input.tags?.map(t => t.toLowerCase()) ?? [];
31
+ let candidates = entries;
32
+ if (tagFilter.length > 0) {
33
+ candidates = entries.filter(e => tagFilter.every(tag => e.tags.map(t => t.toLowerCase()).includes(tag)));
34
+ }
35
+ const candidateIds = new Set(candidates.map(e => e.id));
36
+ const entryMap = new Map(entries.map(e => [e.id, e]));
37
+ const bm25Results = bm25Search(corpus, input.query).filter(r => candidateIds.has(r.id));
38
+ const queryTokens = tokenize(input.query);
39
+ const boosted = bm25Results
40
+ .map(r => {
41
+ const entry = entryMap.get(r.id);
42
+ const score = r.score
43
+ * recencyBoost(entry.created, recencyBoostDays, recencyBoostMax)
44
+ * accessBoost(entry.access_count);
45
+ return { entry, score };
46
+ })
47
+ .sort((a, b) => b.score - a.score);
48
+ const totalMatched = boosted.length;
49
+ const results = boosted.slice(0, limit).map(({ entry, score }) => ({
50
+ id: entry.id,
51
+ title: entry.title,
52
+ excerpt: extractExcerpt(entry.body, queryTokens),
53
+ tags: entry.tags,
54
+ created: entry.created,
55
+ score,
56
+ size_tier: entry.size_tier,
57
+ }));
58
+ return { results, total_matched: totalMatched };
59
+ }
@@ -0,0 +1,199 @@
1
+ import { mkdir } from "fs/promises";
2
+ import { join, basename } from "path";
3
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
4
+ import { DEFAULT_CONFIG } from "./types.js";
5
+ import { resolveDataPath, generateId, generateFilename, extractTitle } from "./utils.js";
6
+ import { writeEntry, deleteEntry } from "./storage.js";
7
+ import { IndexStore } from "./index-store.js";
8
+ import { computeStats, loadSearchLog, appendSearchLog } from "./stats.js";
9
+ function mergeConfig(raw, workspaceDir) {
10
+ return {
11
+ ...DEFAULT_CONFIG,
12
+ ...raw,
13
+ memoryPath: resolveDataPath(raw.memoryPath, workspaceDir, DEFAULT_CONFIG.memoryPath),
14
+ search: { ...DEFAULT_CONFIG.search, ...(raw.search ?? {}) },
15
+ write: { ...DEFAULT_CONFIG.write, ...(raw.write ?? {}) },
16
+ };
17
+ }
18
+ export default definePluginEntry({
19
+ id: "memory",
20
+ name: "Memory",
21
+ description: "Per-turn-lean memory: small core + on-demand BM25 indexed retrieval",
22
+ async register(api) {
23
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.pluginConfig);
24
+ const config = mergeConfig(api.pluginConfig, workspaceDir);
25
+ const indexedDir = join(config.memoryPath, "indexed");
26
+ const searchLogPath = join(config.memoryPath, "_searches.json");
27
+ await mkdir(indexedDir, { recursive: true });
28
+ const store = new IndexStore();
29
+ await store.loadFromDirectory(indexedDir);
30
+ const { watch } = await import("chokidar");
31
+ const watcher = watch(indexedDir, { ignoreInitial: true });
32
+ watcher
33
+ .on("add", async (filePath) => {
34
+ const filename = basename(filePath);
35
+ if (!filename.endsWith(".md"))
36
+ return;
37
+ const { readEntry } = await import("./storage.js");
38
+ store.add(await readEntry(indexedDir, filename));
39
+ })
40
+ .on("change", async (filePath) => {
41
+ const filename = basename(filePath);
42
+ if (!filename.endsWith(".md"))
43
+ return;
44
+ const { readEntry } = await import("./storage.js");
45
+ store.update(await readEntry(indexedDir, filename));
46
+ })
47
+ .on("unlink", (filePath) => {
48
+ store.removeByFilename(basename(filePath));
49
+ });
50
+ api.registerTool({
51
+ name: "memory_search",
52
+ description: "Search indexed memory. Returns titles, excerpts, tags. Call memory_get for full content.",
53
+ parameters: {
54
+ type: "object",
55
+ properties: {
56
+ query: { type: "string", description: "Free-text search query" },
57
+ tags: { type: "array", items: { type: "string" }, description: "Require ALL of these tags (AND semantics)" },
58
+ limit: { type: "number", description: "Max results to return (default 5, max 20)" },
59
+ },
60
+ required: ["query"],
61
+ },
62
+ async execute(_id, params) {
63
+ const input = params;
64
+ const output = store.search(input, config.search.recencyBoostDays, config.search.recencyBoostMax, config.search.defaultLimit, config.search.maxLimit);
65
+ await appendSearchLog({
66
+ query: input.query,
67
+ tags: input.tags,
68
+ result_count: output.total_matched,
69
+ timestamp: new Date().toISOString(),
70
+ }, searchLogPath);
71
+ return { content: [{ type: "text", text: JSON.stringify(output) }] };
72
+ },
73
+ });
74
+ api.registerTool({
75
+ name: "memory_get",
76
+ description: "Load the full content of a memory entry by id. Updates access metadata.",
77
+ parameters: {
78
+ type: "object",
79
+ properties: { id: { type: "string" } },
80
+ required: ["id"],
81
+ },
82
+ async execute(_id, params) {
83
+ const { id } = params;
84
+ const entry = store.getById(id);
85
+ if (!entry) {
86
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Entry ${id} not found` }) }] };
87
+ }
88
+ const updated = {
89
+ ...entry,
90
+ last_accessed: new Date().toISOString(),
91
+ access_count: entry.access_count + 1,
92
+ };
93
+ await writeEntry(indexedDir, updated);
94
+ store.update(updated);
95
+ return { content: [{ type: "text", text: JSON.stringify({
96
+ id: updated.id, title: updated.title, content: updated.body,
97
+ tags: updated.tags, created: updated.created, updated: updated.updated,
98
+ source: updated.source, size_tier: updated.size_tier,
99
+ last_accessed: updated.last_accessed, access_count: updated.access_count,
100
+ }) }] };
101
+ },
102
+ });
103
+ api.registerTool({
104
+ name: "memory_write",
105
+ description: "Write a new memory entry. Returns the new entry's id.",
106
+ parameters: {
107
+ type: "object",
108
+ properties: {
109
+ content: { type: "string", description: "Markdown body" },
110
+ tags: { type: "array", items: { type: "string" }, description: "Tags for this entry (required)" },
111
+ title: { type: "string", description: "Optional title override; derived from first H1 if absent" },
112
+ source: { type: "string", description: "Origin: session | dreams | manual | import" },
113
+ },
114
+ required: ["content", "tags"],
115
+ },
116
+ async execute(_id, params) {
117
+ const input = params;
118
+ if (config.write.requireTags && input.tags.length < config.write.minTags) {
119
+ return { content: [{ type: "text", text: JSON.stringify({ error: `At least ${config.write.minTags} tag required` }) }] };
120
+ }
121
+ if (input.tags.length > config.write.maxTags) {
122
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Maximum ${config.write.maxTags} tags allowed` }) }] };
123
+ }
124
+ const now = new Date().toISOString();
125
+ const id = generateId();
126
+ const title = input.title ?? extractTitle(input.content, id);
127
+ const filename = generateFilename(id, title);
128
+ const entry = {
129
+ id, created: now, updated: now, tags: input.tags,
130
+ source: input.source ?? "session",
131
+ score: 0.5, size_tier: "full",
132
+ last_accessed: now, access_count: 0,
133
+ title, body: input.content, filename,
134
+ };
135
+ await writeEntry(indexedDir, entry);
136
+ store.add(entry);
137
+ return { content: [{ type: "text", text: JSON.stringify({ id }) }] };
138
+ },
139
+ });
140
+ api.registerTool({
141
+ name: "memory_supersede",
142
+ description: "Replace an outdated memory entry with new content. Old entry is deleted; reason is appended to new entry.",
143
+ parameters: {
144
+ type: "object",
145
+ properties: {
146
+ old_id: { type: "string" },
147
+ new_content: { type: "string" },
148
+ new_tags: { type: "array", items: { type: "string" } },
149
+ reason: { type: "string" },
150
+ },
151
+ required: ["old_id", "new_content", "new_tags", "reason"],
152
+ },
153
+ async execute(_id, params) {
154
+ const input = params;
155
+ const old = store.getById(input.old_id);
156
+ if (!old) {
157
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Entry ${input.old_id} not found` }) }] };
158
+ }
159
+ await deleteEntry(indexedDir, old.filename);
160
+ store.removeById(input.old_id);
161
+ const now = new Date().toISOString();
162
+ const id = generateId();
163
+ const body = input.new_content + `\n\n---\n*Supersedes ${input.old_id}: ${input.reason}*`;
164
+ const title = extractTitle(body, id);
165
+ const filename = generateFilename(id, title);
166
+ const entry = {
167
+ id, created: now, updated: now, tags: input.new_tags,
168
+ source: "session", score: 0.5, size_tier: "full",
169
+ last_accessed: now, access_count: 0,
170
+ title, body, filename,
171
+ };
172
+ await writeEntry(indexedDir, entry);
173
+ store.add(entry);
174
+ return { content: [{ type: "text", text: JSON.stringify({ new_id: id }) }] };
175
+ },
176
+ });
177
+ api.registerTool({
178
+ name: "memory_stats",
179
+ description: "Return statistics about the memory corpus: entry count, size, top tags, recent activity.",
180
+ parameters: { type: "object", properties: {} },
181
+ async execute(_id, _params) {
182
+ return { content: [{ type: "text", text: JSON.stringify(computeStats(store.getAll())) }] };
183
+ },
184
+ });
185
+ api.registerTool({
186
+ name: "memory_recent_searches",
187
+ description: "Return recent search queries with result counts, for tuning and gap analysis.",
188
+ parameters: {
189
+ type: "object",
190
+ properties: { limit: { type: "number", description: "Number of recent searches to return (default 20)" } },
191
+ },
192
+ async execute(_id, params) {
193
+ const { limit = 20 } = (params ?? {});
194
+ const log = await loadSearchLog(searchLogPath);
195
+ return { content: [{ type: "text", text: JSON.stringify(log.slice(-limit)) }] };
196
+ },
197
+ });
198
+ },
199
+ });
@@ -0,0 +1,47 @@
1
+ import { readFile, writeFile, mkdir } from "fs/promises";
2
+ import { dirname } from "path";
3
+ export function computeStats(entries) {
4
+ const now = Date.now();
5
+ const sevenDays = 7 * 86_400_000;
6
+ const tagCounts = new Map();
7
+ let totalSizeBytes = 0;
8
+ let totalTokens = 0;
9
+ let createdRecently = 0;
10
+ let accessedRecently = 0;
11
+ for (const e of entries) {
12
+ totalSizeBytes += e.body.length + 200;
13
+ totalTokens += e.body.split(/\s+/).filter(Boolean).length;
14
+ for (const tag of e.tags)
15
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
16
+ if (now - new Date(e.created).getTime() < sevenDays)
17
+ createdRecently++;
18
+ if (now - new Date(e.last_accessed).getTime() < sevenDays)
19
+ accessedRecently++;
20
+ }
21
+ const top_tags = [...tagCounts.entries()]
22
+ .sort((a, b) => b[1] - a[1])
23
+ .slice(0, 20)
24
+ .map(([tag, count]) => ({ tag, count }));
25
+ return {
26
+ total_entries: entries.length,
27
+ total_size_bytes: totalSizeBytes,
28
+ avg_tokens_per_entry: entries.length > 0 ? Math.round(totalTokens / entries.length) : 0,
29
+ top_tags,
30
+ created_last_7_days: createdRecently,
31
+ accessed_last_7_days: accessedRecently,
32
+ };
33
+ }
34
+ export async function loadSearchLog(logPath) {
35
+ try {
36
+ return JSON.parse(await readFile(logPath, "utf-8"));
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ export async function appendSearchLog(entry, logPath, maxEntries = 50) {
43
+ const log = await loadSearchLog(logPath);
44
+ const updated = [...log, entry].slice(-maxEntries);
45
+ await mkdir(dirname(logPath), { recursive: true });
46
+ await writeFile(logPath, JSON.stringify(updated, null, 2), "utf-8");
47
+ }
@@ -0,0 +1,27 @@
1
+ import { readFile, writeFile, unlink, readdir } from "fs/promises";
2
+ import { join } from "path";
3
+ import { parseEntry, serializeEntry } from "./entry-parser.js";
4
+ import { MEMORY_FILENAME_PATTERN } from "./utils.js";
5
+ export async function readEntry(indexedDir, filename) {
6
+ const raw = await readFile(join(indexedDir, filename), "utf-8");
7
+ return parseEntry(raw, filename);
8
+ }
9
+ export async function writeEntry(indexedDir, entry) {
10
+ await writeFile(join(indexedDir, entry.filename), serializeEntry(entry), "utf-8");
11
+ }
12
+ export async function deleteEntry(indexedDir, filename) {
13
+ await unlink(join(indexedDir, filename));
14
+ }
15
+ export async function listEntryFilenames(indexedDir) {
16
+ try {
17
+ const files = await readdir(indexedDir);
18
+ return files.filter(f => MEMORY_FILENAME_PATTERN.test(f)).sort();
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ export async function loadAllEntries(indexedDir) {
25
+ const filenames = await listEntryFilenames(indexedDir);
26
+ return Promise.all(filenames.map(f => readEntry(indexedDir, f)));
27
+ }
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_CONFIG = {
2
+ memoryPath: "memory",
3
+ search: {
4
+ defaultLimit: 5,
5
+ maxLimit: 20,
6
+ recencyBoostDays: 30,
7
+ recencyBoostMax: 1.2,
8
+ },
9
+ write: {
10
+ requireTags: true,
11
+ minTags: 1,
12
+ maxTags: 12,
13
+ },
14
+ };
@@ -0,0 +1,38 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ export function resolvePath(p) {
5
+ return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
6
+ }
7
+ export function resolveDataPath(override, workspaceDir, defaultRelative) {
8
+ if (!override)
9
+ return join(workspaceDir, defaultRelative);
10
+ if (override.startsWith('/') || override.startsWith('~/'))
11
+ return resolvePath(override);
12
+ return join(workspaceDir, override);
13
+ }
14
+ export function generateId(date = new Date()) {
15
+ const dateStr = date.toISOString().slice(0, 10);
16
+ const suffix = randomBytes(4).toString("hex");
17
+ return `mem_${dateStr}_${suffix}`;
18
+ }
19
+ export function slugify(text) {
20
+ return text
21
+ .toLowerCase()
22
+ .replace(/[^\w\s-]/g, "")
23
+ .replace(/\s+/g, "-")
24
+ .replace(/-+/g, "-")
25
+ .slice(0, 60)
26
+ .replace(/^-|-$/g, "");
27
+ }
28
+ export function generateFilename(id, title, date = new Date()) {
29
+ const dateStr = date.toISOString().slice(0, 10);
30
+ const slug = slugify(title) || "untitled";
31
+ const suffix = id.slice(-8);
32
+ return `${dateStr}-${slug}-${suffix}.md`;
33
+ }
34
+ export function extractTitle(body, fallback) {
35
+ const match = body.match(/^#\s+(.+)$/m);
36
+ return match?.[1]?.trim() ?? fallback;
37
+ }
38
+ export const MEMORY_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9-]+-[a-z0-9]{8}\.md$/;
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "memory",
3
+ "name": "Memory",
4
+ "version": "0.1.0",
5
+ "description": "Per-turn-lean memory: small always-loaded core + on-demand BM25 search over indexed markdown files",
6
+ "contracts": {
7
+ "tools": ["memory_search", "memory_get", "memory_write", "memory_supersede", "memory_stats", "memory_recent_searches"]
8
+ },
9
+ "activation": { "onStartup": true },
10
+ "configSchema": {
11
+ "type": "object",
12
+ "additionalProperties": true
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@akalsey/openclaw-memory",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "exports": {
7
+ ".": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "openclaw.plugin.json",
12
+ "README.md",
13
+ "SKILL.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "openclaw": {
23
+ "extensions": [
24
+ "./dist/index.js"
25
+ ],
26
+ "compat": {
27
+ "pluginApi": ">=2026.3.24-beta.2",
28
+ "minGatewayVersion": "2026.3.24-beta.2"
29
+ },
30
+ "install": {
31
+ "localPath": "."
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "chokidar": "^3.6.0",
36
+ "gray-matter": "^4.0.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "openclaw": "latest",
41
+ "typescript": "^5.5.0",
42
+ "vitest": "^2.0.0"
43
+ },
44
+ "peerDependencies": {
45
+ "openclaw": ">=2026.3.24-beta.2"
46
+ }
47
+ }