@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 +135 -0
- package/SKILL.md +44 -0
- package/dist/index.js +1 -0
- package/dist/src/bm25.js +58 -0
- package/dist/src/entry-parser.js +26 -0
- package/dist/src/index-store.js +57 -0
- package/dist/src/search.js +59 -0
- package/dist/src/service.js +199 -0
- package/dist/src/stats.js +47 -0
- package/dist/src/storage.js +27 -0
- package/dist/src/types.js +14 -0
- package/dist/src/utils.js +38 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +47 -0
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";
|
package/dist/src/bm25.js
ADDED
|
@@ -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,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
|
+
}
|