@clawhub-flexrox-5cc2e5127e
Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles,...
---
name: pdf-generator
description: "Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles, tables, lists, highlights, images, and page breaks. Trigger phrases: Export as PDF, Generate PDF, Create PDF report, Save as PDF, PDF erstellen, PDF generieren, als PDF speichern."
---
# PDF Generator
Generate styled PDF documents from structured JSON data using ReportLab.
## Quick Start
```bash
python scripts/generate_pdf.py --output report.pdf --data '{
"title": "Monthly Report",
"subtitle": "March 2026",
"author": "PragDev",
"sections": [
{"type": "text", "text": "Introduction text here."},
{"type": "highlight", "text": "Key metric: +15%"},
{"type": "list", "items": ["Item 1", "Item 2"]}
]
}'
```
## JSON Schema Reference
See `references/schema.md` for complete schema documentation.
## Output
- PDF saved to path specified by `--output` or `data.output`
- Default: `output.pdf` in current directory
## Tips
- Use `accent_color` and `header_color` for brand colors
- Tables auto-alternate row backgrounds
- Images must exist at the specified path
- Page breaks create new pages
FILE:references/schema.md
# PDF Generator Schema Reference
## Top-Level Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Document title (heading1) |
| `subtitle` | string | Subtitle below title (heading3) |
| `author` | string | Author name |
| `date` | string | Date string |
| `accent_color` | string | Accent color hex (default: #e94560) |
| `header_color` | string | Table header color (default: #1a1a2e) |
| `styles` | object | Custom style overrides |
| `sections` | array | Content sections |
| `output` | string | Output PDF path (alternative to CLI --output) |
## Section Types
### text
```json
{"type": "text", "text": "Body paragraph text."}
```
### heading
```json
{"type": "heading", "text": "Section heading"}
```
### subheading
```json
{"type": "subheading", "text": "Subheading text"}
```
### highlight
```json
{"type": "highlight", "text": "Important callout in accent color."}
```
### list
```json
{"type": "list", "items": ["First item", "Second item", "Third item"]}
```
### table
```json
{
"type": "table",
"data": [
["Column 1", "Column 2", "Column 3"],
["Value 1", "Value 2", "Value 3"],
["Value 4", "Value 5", "Value 6"]
]
}
```
- First row is treated as header (bold, white text on header_color background)
- Rows alternate white/#f5f5f5
### image
```json
{
"type": "image",
"path": "/absolute/path/to/image.png",
"width": 120,
"height": 80
}
```
- width/height in mm (default: 150x80)
- Path must be absolute or relative to script execution
### pagebreak
```json
{"type": "pagebreak"}
```
## Example: Mautic Campaign Report
```json
{
"title": "Mautic Kampagnenbericht",
"subtitle": "Q1 2026",
"author": "PragDev-Mautic",
"date": "2026-04-27",
"accent_color": "#e94560",
"sections": [
{"type": "heading", "text": "Kampagnenbersicht"},
{"type": "table", "data": [
["Kampagne", "Gesendet", "Offen", "Klicks"],
["Newsletter April", "1,234", "456", "89"],
["Product Launch", "2,500", "890", "234"]
]},
{"type": "pagebreak"},
{"type": "heading", "text": "Top Kontakte"},
{"type": "list", "items": ["Kontakt A", "Kontakt B", "Kontakt C"]}
]
}
```
FILE:scripts/generate_pdf.py
#!/usr/bin/env python3
"""Generate styled PDF documents from structured data."""
import sys
import json
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, PageBreak
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.lib import colors
def parse_args():
"""Parse command line arguments."""
args = sys.argv[1:]
input_data = {}
output_path = "output.pdf"
json_input = None
for i, arg in enumerate(args):
if arg == "--output" and i + 1 < len(args):
output_path = args[i + 1]
elif arg == "--data" and i + 1 < len(args):
json_input = args[i + 1]
elif arg == "--help" or arg == "-h":
print_usage()
sys.exit(0)
# Handle JSON input
if json_input:
try:
input_data = json.loads(json_input)
except json.JSONDecodeError:
with open(json_input) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
# Handle positional arguments (JSON files)
for arg in args:
if not arg.startswith("--") and arg not in ("--data",):
if os.path.exists(arg):
with open(arg) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
break
return input_data, output_path
def print_usage():
usage = """Usage: generate_pdf.py [--output <path>] [--data '<json>'] [data.json]
JSON schema:
{
"title": "Document Title",
"subtitle": "Optional subtitle",
"author": "Author Name",
"date": "2026-04-27",
"accent_color": "#e94560",
"header_color": "#1a1a2e",
"sections": [
{"type": "heading", "text": "Section Title"},
{"type": "text", "text": "Body text here."},
{"type": "highlight", "text": "Important callout."},
{"type": "list", "items": ["Item 1", "Item 2"]},
{"type": "table", "data": [["Col1", "Col2"], ["Val1", "Val2"]]},
{"type": "image", "path": "/path/to/image.png", "width": 100, "height": 60},
{"type": "pagebreak"}
]
}"""
print(usage)
def create_styles(custom_styles=None):
"""Create named styles for the document."""
styles = getSampleStyleSheet()
defaults = {
"heading1": {"fontSize": 24, "leading": 30, "textColor": "#1a1a2e", "spaceAfter": 20, "fontName": "Helvetica-Bold"},
"heading2": {"fontSize": 18, "leading": 24, "textColor": "#16213e", "spaceAfter": 12, "fontName": "Helvetica-Bold"},
"heading3": {"fontSize": 14, "leading": 18, "textColor": "#0f3460", "spaceAfter": 8, "fontName": "Helvetica-Bold"},
"body": {"fontSize": 11, "leading": 16, "textColor": "#2d2d2d", "spaceAfter": 8, "fontName": "Helvetica"},
"caption": {"fontSize": 9, "leading": 12, "textColor": "#666666", "spaceAfter": 4, "fontName": "Helvetica-Oblique"},
"highlight": {"fontSize": 12, "leading": 16, "textColor": "#e94560", "fontName": "Helvetica-Bold"},
}
custom = custom_styles or {}
for name, props in {**defaults, **custom}.items():
styles.add(ParagraphStyle(name=name, **props))
return styles
def build_document(data, output_path, styles):
"""Build the PDF document from input data."""
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=20*mm,
rightMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm,
title=data.get("title", ""),
author=data.get("author", ""),
subject=data.get("subject", ""),
)
story = []
# Header color
if "header_color" in data:
header_color = HexColor(data["header_color"])
else:
header_color = HexColor("#1a1a2e")
# Title
if "title" in data:
story.append(Paragraph(data["title"], styles["heading1"]))
story.append(Spacer(1, 5*mm))
# Subtitle
if "subtitle" in data:
story.append(Paragraph(data["subtitle"], styles["heading3"]))
story.append(Spacer(1, 10*mm))
# Author/date line
meta = []
if "author" in data:
meta.append(f"Autor: {data['author']}")
if "date" in data:
meta.append(f"Datum: {data['date']}")
if meta:
story.append(Paragraph(" | ".join(meta), styles["caption"]))
story.append(Spacer(1, 15*mm))
# Horizontal rule
if "accent_color" in data:
accent = HexColor(data["accent_color"])
else:
accent = HexColor("#e94560")
rule_table = Table([[""]], colWidths=[170*mm], rowHeights=[2*mm])
rule_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), accent),
]))
story.append(rule_table)
story.append(Spacer(1, 10*mm))
# Sections
for section in data.get("sections", []):
sec_type = section.get("type", "text")
if sec_type == "heading":
story.append(Paragraph(section["text"], styles["heading2"]))
story.append(Spacer(1, 5*mm))
elif sec_type == "subheading":
story.append(Paragraph(section["text"], styles["heading3"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "text":
story.append(Paragraph(section["text"], styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "highlight":
story.append(Paragraph(section["text"], styles["highlight"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "list":
for item in section.get("items", []):
story.append(Paragraph(f"• {item}", styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "table":
table_data = section.get("data", [])
if table_data and table_data[0]:
t = Table(table_data)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), header_color),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [white, HexColor("#f5f5f5")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
]))
story.append(t)
story.append(Spacer(1, 8*mm))
elif sec_type == "image":
img_path = section.get("path", "")
if img_path and os.path.exists(img_path):
try:
w = section.get("width", 150*mm)
h = section.get("height", 80*mm)
img = Image(img_path, width=w, height=h)
story.append(img)
story.append(Spacer(1, 5*mm))
except Exception:
pass
elif sec_type == "pagebreak":
story.append(PageBreak())
doc.build(story)
print(f"PDF created: {output_path}")
return output_path
def main():
data, output_path = parse_args()
if not data:
print_usage()
sys.exit(1)
styles = create_styles(data.get("styles"))
build_document(data, output_path, styles)
if __name__ == "__main__":
main()Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles,...
---
name: pdf-generator
description: "Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles, tables, lists, highlights, images, and page breaks. Trigger phrases: Export as PDF, Generate PDF, Create PDF report, Save as PDF, PDF erstellen, PDF generieren, als PDF speichern."
---
# PDF Generator
Generate styled PDF documents from structured JSON data using ReportLab.
## Quick Start
```bash
python scripts/generate_pdf.py --output report.pdf --data '{
"title": "Monthly Report",
"subtitle": "March 2026",
"author": "PragDev",
"sections": [
{"type": "text", "text": "Introduction text here."},
{"type": "highlight", "text": "Key metric: +15%"},
{"type": "list", "items": ["Item 1", "Item 2"]}
]
}'
```
## JSON Schema Reference
See `references/schema.md` for complete schema documentation.
## Output
- PDF saved to path specified by `--output` or `data.output`
- Default: `output.pdf` in current directory
## Tips
- Use `accent_color` and `header_color` for brand colors
- Tables auto-alternate row backgrounds
- Images must exist at the specified path
- Page breaks create new pages
FILE:references/schema.md
# PDF Generator Schema Reference
## Top-Level Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Document title (heading1) |
| `subtitle` | string | Subtitle below title (heading3) |
| `author` | string | Author name |
| `date` | string | Date string |
| `accent_color` | string | Accent color hex (default: #e94560) |
| `header_color` | string | Table header color (default: #1a1a2e) |
| `styles` | object | Custom style overrides |
| `sections` | array | Content sections |
| `output` | string | Output PDF path (alternative to CLI --output) |
## Section Types
### text
```json
{"type": "text", "text": "Body paragraph text."}
```
### heading
```json
{"type": "heading", "text": "Section heading"}
```
### subheading
```json
{"type": "subheading", "text": "Subheading text"}
```
### highlight
```json
{"type": "highlight", "text": "Important callout in accent color."}
```
### list
```json
{"type": "list", "items": ["First item", "Second item", "Third item"]}
```
### table
```json
{
"type": "table",
"data": [
["Column 1", "Column 2", "Column 3"],
["Value 1", "Value 2", "Value 3"],
["Value 4", "Value 5", "Value 6"]
]
}
```
- First row is treated as header (bold, white text on header_color background)
- Rows alternate white/#f5f5f5
### image
```json
{
"type": "image",
"path": "/absolute/path/to/image.png",
"width": 120,
"height": 80
}
```
- width/height in mm (default: 150x80)
- Path must be absolute or relative to script execution
### pagebreak
```json
{"type": "pagebreak"}
```
## Example: Mautic Campaign Report
```json
{
"title": "Mautic Kampagnenbericht",
"subtitle": "Q1 2026",
"author": "PragDev-Mautic",
"date": "2026-04-27",
"accent_color": "#e94560",
"sections": [
{"type": "heading", "text": "Kampagnenbersicht"},
{"type": "table", "data": [
["Kampagne", "Gesendet", "Offen", "Klicks"],
["Newsletter April", "1,234", "456", "89"],
["Product Launch", "2,500", "890", "234"]
]},
{"type": "pagebreak"},
{"type": "heading", "text": "Top Kontakte"},
{"type": "list", "items": ["Kontakt A", "Kontakt B", "Kontakt C"]}
]
}
```
FILE:scripts/generate_pdf.py
#!/usr/bin/env python3
"""Generate styled PDF documents from structured data."""
import sys
import json
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, PageBreak
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.lib import colors
def parse_args():
"""Parse command line arguments."""
args = sys.argv[1:]
input_data = {}
output_path = "output.pdf"
json_input = None
for i, arg in enumerate(args):
if arg == "--output" and i + 1 < len(args):
output_path = args[i + 1]
elif arg == "--data" and i + 1 < len(args):
json_input = args[i + 1]
elif arg == "--help" or arg == "-h":
print_usage()
sys.exit(0)
# Handle JSON input
if json_input:
try:
input_data = json.loads(json_input)
except json.JSONDecodeError:
with open(json_input) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
# Handle positional arguments (JSON files)
for arg in args:
if not arg.startswith("--") and arg not in ("--data",):
if os.path.exists(arg):
with open(arg) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
break
return input_data, output_path
def print_usage():
usage = """Usage: generate_pdf.py [--output <path>] [--data '<json>'] [data.json]
JSON schema:
{
"title": "Document Title",
"subtitle": "Optional subtitle",
"author": "Author Name",
"date": "2026-04-27",
"accent_color": "#e94560",
"header_color": "#1a1a2e",
"sections": [
{"type": "heading", "text": "Section Title"},
{"type": "text", "text": "Body text here."},
{"type": "highlight", "text": "Important callout."},
{"type": "list", "items": ["Item 1", "Item 2"]},
{"type": "table", "data": [["Col1", "Col2"], ["Val1", "Val2"]]},
{"type": "image", "path": "/path/to/image.png", "width": 100, "height": 60},
{"type": "pagebreak"}
]
}"""
print(usage)
def create_styles(custom_styles=None):
"""Create named styles for the document."""
styles = getSampleStyleSheet()
defaults = {
"heading1": {"fontSize": 24, "leading": 30, "textColor": "#1a1a2e", "spaceAfter": 20, "fontName": "Helvetica-Bold"},
"heading2": {"fontSize": 18, "leading": 24, "textColor": "#16213e", "spaceAfter": 12, "fontName": "Helvetica-Bold"},
"heading3": {"fontSize": 14, "leading": 18, "textColor": "#0f3460", "spaceAfter": 8, "fontName": "Helvetica-Bold"},
"body": {"fontSize": 11, "leading": 16, "textColor": "#2d2d2d", "spaceAfter": 8, "fontName": "Helvetica"},
"caption": {"fontSize": 9, "leading": 12, "textColor": "#666666", "spaceAfter": 4, "fontName": "Helvetica-Oblique"},
"highlight": {"fontSize": 12, "leading": 16, "textColor": "#e94560", "fontName": "Helvetica-Bold"},
}
custom = custom_styles or {}
for name, props in {**defaults, **custom}.items():
styles.add(ParagraphStyle(name=name, **props))
return styles
def build_document(data, output_path, styles):
"""Build the PDF document from input data."""
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=20*mm,
rightMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm,
title=data.get("title", ""),
author=data.get("author", ""),
subject=data.get("subject", ""),
)
story = []
# Header color
if "header_color" in data:
header_color = HexColor(data["header_color"])
else:
header_color = HexColor("#1a1a2e")
# Title
if "title" in data:
story.append(Paragraph(data["title"], styles["heading1"]))
story.append(Spacer(1, 5*mm))
# Subtitle
if "subtitle" in data:
story.append(Paragraph(data["subtitle"], styles["heading3"]))
story.append(Spacer(1, 10*mm))
# Author/date line
meta = []
if "author" in data:
meta.append(f"Autor: {data['author']}")
if "date" in data:
meta.append(f"Datum: {data['date']}")
if meta:
story.append(Paragraph(" | ".join(meta), styles["caption"]))
story.append(Spacer(1, 15*mm))
# Horizontal rule
if "accent_color" in data:
accent = HexColor(data["accent_color"])
else:
accent = HexColor("#e94560")
rule_table = Table([[""]], colWidths=[170*mm], rowHeights=[2*mm])
rule_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), accent),
]))
story.append(rule_table)
story.append(Spacer(1, 10*mm))
# Sections
for section in data.get("sections", []):
sec_type = section.get("type", "text")
if sec_type == "heading":
story.append(Paragraph(section["text"], styles["heading2"]))
story.append(Spacer(1, 5*mm))
elif sec_type == "subheading":
story.append(Paragraph(section["text"], styles["heading3"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "text":
story.append(Paragraph(section["text"], styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "highlight":
story.append(Paragraph(section["text"], styles["highlight"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "list":
for item in section.get("items", []):
story.append(Paragraph(f"• {item}", styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "table":
table_data = section.get("data", [])
if table_data and table_data[0]:
t = Table(table_data)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), header_color),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [white, HexColor("#f5f5f5")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
]))
story.append(t)
story.append(Spacer(1, 8*mm))
elif sec_type == "image":
img_path = section.get("path", "")
if img_path and os.path.exists(img_path):
try:
w = section.get("width", 150*mm)
h = section.get("height", 80*mm)
img = Image(img_path, width=w, height=h)
story.append(img)
story.append(Spacer(1, 5*mm))
except Exception:
pass
elif sec_type == "pagebreak":
story.append(PageBreak())
doc.build(story)
print(f"PDF created: {output_path}")
return output_path
def main():
data, output_path = parse_args()
if not data:
print_usage()
sys.exit(1)
styles = create_styles(data.get("styles"))
build_document(data, output_path, styles)
if __name__ == "__main__":
main()Validate, diff, and export DESIGN.md files to ensure consistent design tokens, WCAG compliance, and design system integrity across projects.
# DESIGN.md Skill — Design System Validation & Management
> Validate, diff, and export DESIGN.md files for consistent design systems across projects.
## Overview
DESIGN.md is a format specification (by Google Labs) for describing a visual identity to coding agents. It combines:
- **YAML front matter** — Machine-readable design tokens
- **Markdown body** — Human-readable design rationale
## Installation
The skill uses `@google/design.md` npm package:
```bash
npm install -g @google/design.md
```
## Commands
### 1. Lint — Validate DESIGN.md
```bash
npx @google/design.md lint DESIGN.md
```
**Checks:**
- Token references resolve (`broken-ref` → error)
- WCAG contrast ratios (`contrast-ratio` → warning)
- Missing primary colors (`missing-primary` → warning)
- Section order (`section-order` → warning)
**Output:** JSON with findings
### 2. Diff — Compare Versions
```bash
npx @google/design.md diff DESIGN.md DESIGN-v2.md
```
**Detects:**
- Added/removed/modified tokens
- Regressions between versions
### 3. Export — Convert to Other Formats
```bash
npx @google/design.md export --format tailwind DESIGN.md > tailwind.theme.json
npx @google/design.md export --format dtcg DESIGN.md > tokens.json
```
### 4. Spec — View Format Specification
```bash
npx @google/design.md spec
npx @google/design.md spec --rules
```
## Quick Start
1. **Read existing DESIGN.md** in project
2. **Run lint** to validate structure
3. **Run export** to generate Tailwind config
4. **Update components** based on findings
## Usage in Coding
When working on UI components:
```bash
# Before editing a component, lint current state
npx @google/design.md lint design-md/markiosi/DESIGN.md
# After changes, diff to check for regressions
npx @google/design.md diff design-md/markiosi/DESIGN.md design-md/markiosi/DESIGN.md.new
# Export updated tokens
npx @google/design.md export --format tailwind design-md/markiosi/DESIGN.md > tailwind.tokens.json
```
## Workflow Integration
1. **Before making UI changes:**
- Read project's DESIGN.md
- Run `lint` to understand current design state
2. **After UI changes:**
- Update DESIGN.md tokens accordingly
- Run `diff` to detect regressions
- Run `lint` to validate WCAG compliance
3. **For new components:**
- Check DESIGN.md for existing component patterns
- Define new components in DESIGN.md first
- Use token references (`{colors.primary}`) over hardcoded values
## Resources
- **Spec:** https://github.com/google-labs-code/design.md
- **Stitch Tool:** https://stitch.withgoogle.com/
- **Design Tokens Format:** https://www.designtokens.org/
Make FaceTime audio calls on macOS by phone number or contact using the Phone app with AppleScript or tel: URLs; requires FaceTime enabled.
---
name: phone-call
description: Make and manage phone calls via the macOS Phone app using AppleScript or tel: URLs. Initiates FaceTime audio calls to contacts or phone numbers.
homepage: https://support.apple.com/guide/mac-help/mchlp2466/mac
metadata:
{
"openclaw":
{
"emoji": "📞",
"os": ["darwin"],
"requires": { "apps": ["FaceTime"] },
"install":
[
{
"id": "facetime-enabled",
"kind": "manual",
"label": "Enable FaceTime in System Settings > FaceTime",
},
],
},
}
---
# Phone Call Skill 📞
Control the macOS Phone/FaceTime app to make calls to contacts or phone numbers.
## When to Use
✅ **USE this skill when:**
- User wants to make a phone call
- User says "call", "anrufen", "telefonieren"
- User provides a phone number or contact name
- User wants to initiate a FaceTime audio call
## When NOT to Use
❌ **DON'T use this skill when:**
- User wants to send a text message → use imsg skill
- User wants to video call → use FaceTime manually
- User wants to manage contacts → use apple-contacts skill
- User is on iOS not macOS
## Setup
1. **Enable FaceTime** on your Mac:
- System Settings → FaceTime → Turn On
- Sign in with Apple ID
- Grant permissions for Phone app
2. **Verify FaceTime is working:**
```bash
open -a FaceTime
```
## How It Works
Uses `open tel:` URLs to initiate FaceTime audio calls through the system Phone/FaceTime app.
## Usage
### Make a Call
**By phone number:**
```bash
open "tel:+491234567890"
```
**By contact name (via AppleScript):**
```bash
osascript -e 'tell application "FaceTime" to make call to "+491234567890"'
```
### Call Management Commands
**List recent calls (via Phone app logs):**
```bash
osascript -e 'tell application "System Events" to keystroke "m" using command down'
```
**End current call:**
```bash
osascript -e 'tell application "FaceTime" to hang up'
```
## Examples
### Basic Call Flow
```bash
# 1. User says "ruf max an" or "call +491234567890"
# 2. Confirm the number:
echo "Calling +491234567890..."
# 3. Make the call:
open "tel:+491234567890"
# 4. Wait a moment for FaceTime to initiate
sleep 2
# 5. Confirm call started
echo "Call initiated to +491234567890"
```
### With Contact Lookup
```bash
# Look up contact's phone number first
CONTACT="Max Mustermann"
NUMBER=$(osascript -e "tell application \"Contacts\" to phone of person \"$CONTACT\" as string" 2>/dev/null)
if [ -n "$NUMBER" ]; then
open "tel:$NUMBER"
echo "Calling $CONTACT: $NUMBER"
else
echo "Contact not found"
fi
```
## Number Formatting
Use international format:
- Germany: `+49` + area code (without 0) + number
- Austria: `+43` + area code (without 0) + number
- Switzerland: `+41` + area code (without 0) + number
Examples:
- Mobile: `+4915112345678`
- Landline: `+493012345678`
## Notes
- **Privacy:** The call is visible on your Mac's screen
- **Handoff:** The call can be picked up on your iPhone if enabled (Handoff)
- **Audio:** Uses your Mac's microphone/speakers or paired Bluetooth device
- **Timing:** FaceTime may ask for permission on first call
## Troubleshooting
**FaceTime won't open:**
- Check System Settings → Privacy & Security → FaceTime → Allow
- Restart FaceTime: `killall FaceTime`
**Number not recognized:**
- Ensure international format (+49...)
- Remove spaces, dashes, parentheses
**Permission denied:**
- System Settings → Privacy & Security → Automation → Enable OpenClaw
FILE:scripts/call.sh
#!/bin/bash
# Phone Call Script - Make calls via FaceTime
# Usage: call.sh +491234567890
NUMBER="$1"
if [ -z "$NUMBER" ]; then
echo "Usage: call.sh <phone-number>"
echo "Example: call.sh +491234567890"
exit 1
fi
# Clean number - remove spaces, dashes
CLEAN_NUMBER=$(echo "$NUMBER" | tr -d '[:space:]\-\(\)')
echo "Calling $CLEAN_NUMBER..."
# Make the call via FaceTime tel: URL
open "tel:$CLEAN_NUMBER"
echo "Call initiated!"
Brain-like local memory plugin for OpenClaw — stores, searches, and injects memories with importance scoring, entity extraction, and automatic consolidation.
---
name: Local Memory
description: Brain-like local memory plugin for OpenClaw — stores, searches, and injects memories with importance scoring, entity extraction, and automatic consolidation.
---
# 🧠 Local Memory Plugin v0.4
**A brain-like memory system for OpenClaw. Remembers what matters, forgets what doesn't, and builds a persistent understanding of you over time.**
> Zero-config, no external service, no API key, works out of the box.
## Features
### 🧠 Brain-Like Memory Architecture
- **Hierarchical Memory**: Exchanges → Summaries → Profile
- **Importance Scoring**: Each memory scored 0-1 based on significance
- **Time Decay**: Importance decreases over time (adjustable rate)
- **Entity Tracking**: Extracts and tracks people, places, things
- **Semantic Chunking**: Long content auto-split into manageable pieces
### 🔍 Smart Recall
- **Multi-Factor Scoring**: Combines relevance, importance, AND recency
- **Profile Injection**: Builds and injects user profile periodically
- **Context Window**: Tracks conversation turns and manages memory refresh
### 💾 Intelligent Capture
- **Significance Detection**: Only captures meaningful content
- **Auto-Deduplication**: Won't store the same thing twice
- **Periodic Consolidation**: Summarizes accumulated content when context grows long
- **Category Detection**: Auto-categorizes as preference, fact, decision, entity, skill
### 🗑️ Self-Maintaining
- **Auto-Pruning**: Removes old/unimportant memories when limit reached
- **Importance Protection**: High-value memories kept longer
- **Memory Stats**: Track memory health and composition
## Tools
| Tool | Description |
|------|-------------|
| `local_memory_search` | Search memories by natural language (semantic) |
| `local_memory_store` | Manually save a specific memory |
| `local_memory_list` | List all memories, optionally filtered by category |
| `local_memory_profile` | View user profile (entities, preferences, facts) |
| `local_memory_stats` | View memory statistics |
| `local_memory_recent` | Get recently accessed memories |
| `local_memory_forget` | Delete memory matching a query |
| `local_memory_wipe` | Delete ALL memories (irreversible) |
## How It Works
### Memory Lifecycle
1. **Capture** → User + Assistant exchange
2. **Significance Assessment** → Score based on patterns (decisions score high, greetings low)
3. **Storage** → If significant enough, store with extracted entities and tags
4. **Importance Calculation** → Based on category, length, entities, source
5. **Decay Over Time** → Importance decreases exponentially
6. **Recall** → On query, combine TF-IDF relevance + importance + recency
7. **Pruning** → When max reached, lowest combined-score memories removed
### Recall Scoring Formula
```
score = (relevanceWeight × tfidf_similarity)
+ (importanceWeight × decayed_importance)
+ (recencyWeight × recency_factor)
```
### Significance Detection Patterns
| Pattern | Category | Weight |
|---------|----------|--------|
| entschieden, geplant, wird, werden | decision | 0.30 |
| ich bin, mein, unser Unternehmen | identity | 0.25 |
| bevorzug, immer, nie, prefer | preference | 0.25 |
| api_key, password, token | credential | 0.20 |
| skill, können, fähig | skill | 0.20 |
| projekt, build, deploy | project | 0.15 |
## Configuration
```json
{
"autoRecall": true,
"autoCapture": true,
"captureInterval": 8,
"captureSignificantOnly": true,
"minSignificanceScore": 0.5,
"profileFrequency": 15,
"includeProfileOnFirstTurn": true,
"maxRecallResults": 5,
"similarityThreshold": 0.35,
"maxMemoryInjections": 3,
"contextBudget": 2000,
"maxMemories": 500,
"pruneOlderThanDays": 30,
"decayRate": 0.05,
"chunkSize": 800,
"importanceWeight": 0.25,
"recencyWeight": 0.25,
"relevanceWeight": 0.5
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `autoRecall` | `true` | Inject relevant memories before each turn |
| `autoCapture` | `true` | Auto-capture conversation exchanges |
| `captureInterval` | `8` | Capture every N turns (higher = less storage) |
| `captureSignificantOnly` | `true` | Only capture significant content |
| `minSignificanceScore` | `0.5` | Min score to capture (higher = stricter) |
| `profileFrequency` | `15` | Inject profile every N turns (higher = less context) |
| `maxRecallResults` | `5` | Max memories injected per turn |
| `similarityThreshold` | `0.35` | Min relevance to inject |
| `maxMemoryInjections` | `3` | **Max memories to show per recall** |
| `contextBudget` | `2000` | **Max chars of memory context injected** |
| `maxMemories` | `500` | Maximum memories to keep |
| `pruneOlderThanDays` | `30` | Auto-delete memories older than N days |
| `decayRate` | `0.05` | Importance decay speed |
| `importanceWeight` | `0.25` | Weight of importance in scoring |
| `recencyWeight` | `0.25` | Weight of recency in scoring |
| `relevanceWeight` | `0.5` | Weight of TF-IDF relevance in scoring |
## Data Storage
All memories stored locally in:
```
~/.openclaw/memory/<containerTag>.json
```
Default: `~/.openclaw/memory/openclaw_local_memory.json`
## Privacy
- **100% Local**: No data leaves your machine
- **You Control**: Auto-capture can be disabled
- **Significance Filter**: Won't store every random message
- **No External APIs**: No internet required
## Requirements
- OpenClaw 2026.1.29 or later
- Node.js (built-in TF-IDF, no external dependencies)
## Tips
### For Best Results
1. Let it run for a few days — memory improves over time
2. Manually store important facts with `local_memory_store`
3. Check profile with `local_memory_profile` periodically
4. Adjust `importanceWeight`, `recencyWeight`, `relevanceWeight` to your preference
### If Context Gets Long
- Reduce `summariseThreshold` to trigger earlier consolidation
- Increase `decayRate` to forget older stuff faster
- Lower `maxMemories` to prune more aggressively
### Forgot Something?
- Use `local_memory_forget query="what to forget"` to delete
- Use `local_memory_search` to find what you're looking for
FILE:_meta.json
{
"ownerId": "kn7619jsm9pb0efh817rgvzy4583prgq",
"slug": "openclaw-local-memory",
"version": "0.4.2",
"publishedAt": 1774607820773
}
FILE:index.ts
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { LocalMemoryStore } from "./lib/store.js";
import { buildCaptureHandler, buildRecallHandler } from "./lib/hooks.js";
import {
registerSearchTool,
registerStoreTool,
registerForgetTool,
registerWipeTool,
registerListTool,
registerProfileTool,
registerStatsTool,
registerRecentTool,
} from "./lib/tools.js";
// ─── Config Schema ───────────────────────────────────────────────────────────
const ConfigSchema = {
type: "object",
additionalProperties: false,
properties: {
containerTag: { type: "string", default: "openclaw_local_memory" },
autoRecall: { type: "boolean", default: true },
autoCapture: { type: "boolean", default: true },
maxRecallResults: { type: "number", default: 5 }, // REDUCED from 10
similarityThreshold: { type: "number", default: 0.35 }, // SLIGHTLY HIGHER
debug: { type: "boolean", default: false },
// Smart capture - CONSERVATIVE to save context
captureInterval: { type: "number", default: 8 }, // INCREASED (less frequent)
summariseThreshold: { type: "number", default: 60000 }, // REDUCED (more frequent consolidation)
captureSignificantOnly: { type: "boolean", default: true },
pruneAfterCapture: { type: "boolean", default: true },
minSignificanceScore: { type: "number", default: 0.5 }, // INCREASED (stricter)
// Profile settings - CONSERVATIVE
profileFrequency: { type: "number", default: 15 }, // INCREASED (less frequent)
includeProfileOnFirstTurn: { type: "boolean", default: true },
// Memory management
maxMemories: { type: "number", default: 500 },
pruneOlderThanDays: { type: "number", default: 30 },
decayRate: { type: "number", default: 0.05 },
chunkThreshold: { type: "number", default: 2000 },
chunkSize: { type: "number", default: 800 }, // REDUCED (smaller chunks)
maxChunks: { type: "number", default: 3 }, // REDUCED
// Context limiting
maxMemoryInjections: { type: "number", default: 3 }, // MAX 3 per recall
contextBudget: { type: "number", default: 2000 }, // MAX 2000 chars
// Scoring weights
importanceWeight: { type: "number", default: 0.25 }, // SLIGHTLY LOWER
recencyWeight: { type: "number", default: 0.25 }, // SLIGHTLY LOWER
relevanceWeight: { type: "number", default: 0.5 }, // HIGHER (relevance matters more)
},
};
type LocalMemoryConfig = {
containerTag?: string;
autoRecall?: boolean;
autoCapture?: boolean;
maxRecallResults?: number;
similarityThreshold?: number;
debug?: boolean;
captureInterval?: number;
summariseThreshold?: number;
captureSignificantOnly?: boolean;
pruneAfterCapture?: boolean;
minSignificanceScore?: number;
profileFrequency?: number;
includeProfileOnFirstTurn?: boolean;
maxMemories?: number;
pruneOlderThanDays?: number;
decayRate?: number;
chunkThreshold?: number;
chunkSize?: number;
maxChunks?: number;
maxMemoryInjections?: number;
contextBudget?: number;
importanceWeight?: number;
recencyWeight?: number;
relevanceWeight?: number;
};
// ─── Plugin ──────────────────────────────────────────────────────────────────
export default definePluginEntry({
id: "openclaw-local-memory",
name: "Local Memory",
description: "Brain-like local memory for OpenClaw — stores, searches, and injects memories with importance scoring, entity extraction, and automatic consolidation.",
kind: "memory",
configSchema: ConfigSchema,
register(api) {
const raw = api.pluginConfig;
const cfg: LocalMemoryConfig = raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const log = (level: "info" | "warn" | "debug", msg: string, data?: Record<string, unknown>) => {
if (cfg.debug || level !== "debug") {
api.logger[level === "info" ? "info" : level === "warn" ? "warn" : "info"](
`[local-memory] msg`,
data ?? {}
);
}
};
// ── Init store ──────────────────────────────────────────────────────────
const store = new LocalMemoryStore({
containerTag: cfg.containerTag ?? "openclaw_local_memory",
debug: cfg.debug ?? false,
maxMemories: cfg.maxMemories ?? 500,
pruneOlderThanDays: cfg.pruneOlderThanDays ?? 30,
decayRate: cfg.decayRate ?? 0.05,
chunkThreshold: cfg.chunkThreshold ?? 2000,
chunkSize: cfg.chunkSize ?? 1000,
maxChunks: cfg.maxChunks ?? 5,
importanceWeight: cfg.importanceWeight ?? 0.3,
recencyWeight: cfg.recencyWeight ?? 0.3,
relevanceWeight: cfg.relevanceWeight ?? 0.4,
});
log("info", "initialized", {
container: store.containerTag,
backend: "tfidf-v2",
features: [
"significance_detection",
"entity_extraction",
"importance_scoring",
"time_decay",
"semantic_chunking",
"profile_building",
"context_pruning",
],
});
// ── Register Tools ────────────────────────────────────────────────────────
registerSearchTool(api, store, cfg, log);
registerStoreTool(api, store, cfg, log);
registerForgetTool(api, store, cfg, log);
registerWipeTool(api, store, log);
registerListTool(api, store, cfg, log);
registerProfileTool(api, store, log);
registerStatsTool(api, store, log);
registerRecentTool(api, store, log);
// ── Memory Prompt Section ─────────────────────────────────────────────────
api.registerMemoryPromptSection(({ agentId, sessionKey }) => {
return ""; // Filled by recall handler
});
// ── Hooks ────────────────────────────────────────────────────────────────
const captureHandler = buildCaptureHandler(store, cfg, log);
const recallHandler = cfg.autoRecall ? buildRecallHandler(store, cfg, log) : null;
api.on("before_agent_start", (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
const sessionKey = ctx.sessionKey as string | undefined;
if (sessionKey) {
const userPrompt = extractUserPrompt(event);
if (userPrompt) {
captureHandler.registerUserMessage(userPrompt, sessionKey, 0);
}
}
if (recallHandler) {
return recallHandler(event, ctx);
}
});
if (cfg.autoCapture) {
api.on("agent_end", (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
const sessionKey = ctx.sessionKey as string | undefined;
return captureHandler.handle(event, ctx, sessionKey);
});
}
// ── Service ─────────────────────────────────────────────────────────────
api.registerService({
id: "openclaw-local-memory",
start: () => {
log("info", "🧠 Local Memory started");
log("info", ` Container: cfg.containerTag ?? "openclaw_local_memory"`);
log("info", ` Auto-capture: cfg.autoCapture ?? true`);
log("info", ` Auto-recall: cfg.autoRecall ?? true`);
},
stop: () => {
log("info", "service stopped");
},
});
},
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function extractUserPrompt(event: Record<string, unknown>): string {
if (typeof event.prompt === "string") return event.prompt;
if (Array.isArray(event.messages)) {
for (const msg of event.messages as Record<string, unknown>[]) {
if (msg.role === "user") {
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c) => (typeof c === "string" ? c : (c as Record<string, unknown>).text ?? ""))
.join(" ");
}
}
}
}
return "";
}
FILE:lib/hooks.ts
/**
* Advanced Brain-Like Memory Hooks
*
* Features:
* - Significance detection (what's worth remembering)
* - Entity extraction
* - Periodic summarization
* - Profile building
* - Smart context pruning
* - Multi-tier memory (exchange → summary → profile)
*/
import type { LocalMemoryStore } from "./store.js";
interface LocalMemoryConfig {
autoRecall?: boolean;
autoCapture?: boolean;
maxRecallResults?: number;
similarityThreshold?: number;
debug?: boolean;
// Smart capture - be MORE AGGRESSIVE to save context
captureInterval?: number; // Capture every N turns (default: 8, higher = less capture)
summariseThreshold?: number; // Token threshold to trigger summary (default: 60000, LOWER = more often)
captureSignificantOnly?: boolean; // Only capture significant content (default: true)
pruneAfterCapture?: boolean; // Clear captured context from session (default: true)
minSignificanceScore?: number; // Min score to be worth capturing (default: 0.5, HIGHER = stricter)
// Profile settings
profileFrequency?: number; // Inject profile every N turns (default: 15, higher = less often)
includeProfileOnFirstTurn?: boolean;
// Context management - BE AGGRESSIVE
maxContextAge?: number; // Max context age in ms before forcing prune
memoryRefreshThreshold?: number; // Refresh memory injection after N turns
maxMemoryInjections?: number; // Max memories to inject per recall (default: 5)
contextBudget?: number; // Max chars of memory context to inject (default: 2000)
}
type LogFn = (level: "info" | "warn" | "debug", msg: string, data?: Record<string, unknown>) => void;
// ─── Significance Detection ───────────────────────────────────────────────────
interface SignificanceResult {
score: number; // 0-1
reasons: string[]; // Why it's significant
category?: string; // Detected category
shouldCapture: boolean;
}
const SIGNIFICANCE_PATTERNS = [
// Decisions and commitments
{ pattern: /\b(entschieden|beschlossen|geplant|wird|werden|machen|setup|konfiguriert|installiert|aktiviert|configured|decided|will use|going with|chose|selected)\b/i, weight: 0.3, reason: "decision" },
// User identity and facts
{ pattern: /\b(ich bin|mein|unser|unser Unternehmen|Name|Email|Konto|team|firma|unternehmen|company|I am|my name|we are)\b/i, weight: 0.25, reason: "identity" },
// Preferences
{ pattern: /\b(bevorzug|präferiert|immer|nie|niemals|nur|like|love|hate|prefer|want|need|never|always)\b/i, weight: 0.25, reason: "preference" },
// Credentials and security
{ pattern: /\b(api[_-]?key|password|secret|token|credential|auth|login|zugang)\b/i, weight: 0.2, reason: "credential" },
// Skills and capabilities
{ pattern: /\b(können|fähig|skill|ability|experienced|proficient|know how|can do|capable)\b/i, weight: 0.2, reason: "skill" },
// Projects and ongoing work
{ pattern: /\b(projekt|project|build|deploy|setup|implement|develop|create|machen)\b/i, weight: 0.15, reason: "project" },
// Errors and problems (worth remembering for context)
{ pattern: /\b(error|bug|issue|problem|fehler|kaputt|nicht|broken|failed|nicht funktioniert)\b/i, weight: 0.1, reason: "problem" },
// Important events
{ pattern: /\b(heute|gestern|morgen|letzte woche|diese woche|gerade eben|soeben)\b/i, weight: 0.1, reason: "temporal" },
// Goals and targets
{ pattern: /\b(ziel|goal|target|milestone|deadline|erfolg|success|fertig|complete)\b/i, weight: 0.15, reason: "goal" },
// Money/business
{ pattern: /\b(geld|cost|price|preis|budget|euro|€|revenue|umsatz|deal|pipeline)\b/i, weight: 0.15, reason: "business" },
];
function assessSignificance(content: string): SignificanceResult {
const reasons: string[] = [];
let totalWeight = 0;
let matchedPatterns = 0;
const lower = content.toLowerCase();
for (const { pattern, weight, reason } of SIGNIFICANCE_PATTERNS) {
if (pattern.test(lower)) {
matchedPatterns++;
totalWeight += weight;
if (!reasons.includes(reason)) {
reasons.push(reason);
}
}
}
// Length bonus (medium length is best - too short = not enough context, too long = probably rambling)
const len = content.length;
let lengthBonus = 0;
if (len >= 50 && len < 500) lengthBonus = 0.1;
else if (len >= 500 && len < 2000) lengthBonus = 0.15;
else if (len >= 2000 && len < 5000) lengthBonus = 0.1;
else if (len >= 5000) lengthBonus = 0;
else if (len < 30) lengthBonus = -0.2;
// Question penalty (questions are less worth capturing)
if (/^\s*(\?|warum|wie|was|wo\b)/i.test(lower.trim())) {
lengthBonus -= 0.1;
}
// URL/file path bonus (technical content is valuable)
if (/\.(com|de|net|org|io|ts|js|md|json|py|sh)\b/.test(content)) {
lengthBonus += 0.1;
}
// Greeting penalty
if (/^hi|^hey|^hallo|^moin|^servus/i.test(lower.trim())) {
lengthBonus -= 0.3;
}
const score = Math.max(0, Math.min(1, totalWeight + lengthBonus));
return {
score,
reasons,
category: reasons[0] || "other",
shouldCapture: score >= 0.4 || matchedPatterns >= 2,
};
}
// ─── Context Window Management ────────────────────────────────────────────────
interface ContextWindow {
turnCount: number;
lastCaptureAt: number;
lastProfileAt: number;
accumulatedContent: string[];
tokenEstimate: number;
}
const contextWindows = new Map<string, ContextWindow>();
function getOrCreateWindow(sessionKey: string): ContextWindow {
if (!contextWindows.has(sessionKey)) {
contextWindows.set(sessionKey, {
turnCount: 0,
lastCaptureAt: Date.now(),
lastProfileAt: 0,
accumulatedContent: [],
tokenEstimate: 0,
});
}
return contextWindows.get(sessionKey)!;
}
function shouldCaptureNow(window: ContextWindow, cfg: LocalMemoryConfig): boolean {
const interval = cfg.captureInterval ?? 5;
const turnsSinceCapture = window.turnCount - Math.floor(window.lastCaptureAt / 1000);
// Capture every N turns
if (interval > 0 && window.turnCount % interval === 0) {
return true;
}
// Capture if context is getting long
if (window.tokenEstimate > (cfg.summariseThreshold ?? 100000)) {
return true;
}
return false;
}
// ─── Capture Handler ─────────────────────────────────────────────────────────
export function buildCaptureHandler(
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
const minSignificance = cfg.minSignificanceScore ?? 0.4;
return {
async registerUserMessage(
userContent: string,
sessionKey: string,
turnIndex: number,
) {
if (userContent.length < 20) return;
if (userContent.startsWith("[") && userContent.includes("agent_end")) return;
const window = getOrCreateWindow(sessionKey);
window.turnCount++;
// Accumulate for later summarization
if (userContent.split(" ").length >= 4) {
window.accumulatedContent.push(userContent);
}
// Check significance
const sig = assessSignificance(userContent);
log("debug", "user message registered", {
turnCount: window.turnCount,
sigScore: sig.score,
sigReasons: sig.reasons,
tokenEstimate: window.tokenEstimate,
});
// Trigger capture if significant
if (sig.shouldCapture && sig.score >= minSignificance) {
window.lastCaptureAt = Date.now();
}
},
async handle(
event: Record<string, unknown>,
ctx: Record<string, unknown>,
sessionKey?: string,
) {
if (!sessionKey) return;
try {
const assistantContent = extractAssistantResponse(event);
if (!assistantContent || assistantContent.length < 5) return;
const window = getOrCreateWindow(sessionKey);
const userContent = window.accumulatedContent.pop() ?? "";
const combinedContent = `[User]: userContent.slice(0, 1500)\n\n[Assistant]: assistantContent.slice(0, 1500)`;
// Assess significance
const sig = assessSignificance(combinedContent);
// Update token estimate
window.tokenEstimate = estimateTokens(event);
// Decide what to do
const shouldPeriodicCapture = window.turnCount % (cfg.captureInterval ?? 5) === 0;
const shouldSummarise = window.tokenEstimate > (cfg.summariseThreshold ?? 100000);
const shouldPrune = cfg.pruneAfterCapture && (sig.shouldCapture || shouldPeriodicCapture);
log("debug", "capture decision", {
sigScore: sig.score,
sigShouldCapture: sig.shouldCapture,
periodic: shouldPeriodicCapture,
summarise: shouldSummarise,
prune: shouldPrune,
tokenEstimate: window.tokenEstimate,
});
if (sig.shouldCapture && sig.score >= minSignificance) {
const id = await store.add(combinedContent, {
sessionKey,
conversationId: sessionKey,
turnIndex: window.turnCount,
messageType: "exchange",
source: "assistant",
category: sig.category as any,
});
log("info", "captured significant exchange", {
id: id.slice(0, 8),
sigScore: sig.score,
sigReasons: sig.reasons,
});
}
if (shouldSummarise) {
// Consolidate accumulated content
await consolidateMemory(store, window, log);
window.tokenEstimate = Math.floor(window.tokenEstimate * 0.3); // Reset after summary
}
} catch (err) {
log("warn", "capture failed", { error: String(err) });
}
},
};
}
// ─── Memory Consolidation ───────────────────────────────────────────────────
async function consolidateMemory(
store: LocalMemoryStore,
window: ContextWindow,
log: LogFn,
): Promise<void> {
if (window.accumulatedContent.length < 2) return;
const summaryText = window.accumulatedContent
.slice(-10) // Last 10 exchanges
.join("\n---\n");
const id = await store.add(summaryText, {
conversationId: window.accumulatedContent.length > 5 ? "consolidated" : undefined,
turnIndex: 0,
messageType: "summary",
source: "system",
category: "context",
});
log("info", "consolidated memory", {
id: id.slice(0, 8),
exchanges: window.accumulatedContent.length,
});
window.accumulatedContent = [];
}
// ─── Recall Handler ──────────────────────────────────────────────────────────
export function buildRecallHandler(
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
const contextWindows = new Map<string, ContextWindow>();
return async (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
try {
const sessionKey = ctx.sessionKey as string | undefined;
if (!sessionKey) return;
const prompt = extractPrompt(event);
if (!prompt || prompt.length < 5) return;
// Get or create window
let window = contextWindows.get(sessionKey);
if (!window) {
window = {
turnCount: 0,
lastCaptureAt: Date.now(),
lastProfileAt: 0,
accumulatedContent: [],
tokenEstimate: estimateTokens(event),
};
contextWindows.set(sessionKey, window);
}
window.turnCount++;
// AGGRESSIVE LIMITING: Fewer results, stricter threshold
const limit = Math.min(cfg.maxRecallResults ?? 10, 5); // MAX 5 memories
const threshold = (cfg.similarityThreshold ?? 0.3) + 0.1; // Stricter threshold
const contextBudget = cfg.contextBudget ?? 2000; // Max chars to inject
const maxInjections = cfg.maxMemoryInjections ?? 3; // Max memories to show
// ─── Build context sections (STRICTLY LIMITED) ─────────────────────────────────
const sections: string[] = [];
// 1. Profile - ONLY if first turn or very rarely
const profileFrequency = cfg.profileFrequency ?? 15;
const includeProfile = cfg.includeProfileOnFirstTurn !== false && window.turnCount <= 1;
if (includeProfile) {
const profile = await store.buildProfile();
// TRUNCATE profile heavily
const profileSection = formatProfileSection(profile, 500); // Max 500 chars
if (profileSection) {
sections.push(profileSection);
window.lastProfileAt = window.turnCount;
}
}
// 2. Relevant memories - STRICTLY LIMITED
const memories = await store.search(prompt, maxInjections, threshold);
if (memories.length > 0) {
sections.push(formatMemorySection(memories, contextBudget));
}
// 3. ONLY if context is very short (first 2 turns)
if (window.turnCount <= 2) {
const recent = await store.getRecent(2);
if (recent.length > 0) {
sections.push(formatRecentSection(recent, 800));
}
}
if (sections.length === 0) return;
// FINAL BUDGET CHECK: Don't exceed context budget
let context = sections.join("\n\n");
if (context.length > contextBudget) {
context = context.slice(0, contextBudget) + "\n...[memory truncated to save context]";
}
// Inject into prependContext
if (Array.isArray(event.prependContext)) {
event.prependContext.push(context);
}
log("debug", "recalled context (slim)", {
turnCount: window.turnCount,
profileIncluded: includeProfile,
memoriesFound: memories.length,
contextLength: context.length,
budget: contextBudget,
});
} catch (err) {
log("warn", "recall failed", { error: String(err) });
}
};
}
// ─── Memory Pruning ─────────────────────────────────────────────────────────
/**
* Signal that context can be pruned after significant memory capture
*/
export function shouldPruneContext(
ctx: Record<string, unknown>,
sessionKey: string,
): boolean {
const window = (ctx as any).__memoryWindow as ContextWindow | undefined;
if (!window) return false;
// Prune if we just captured something significant
const timeSinceCapture = Date.now() - window.lastCaptureAt;
return timeSinceCapture < 60000; // Within last minute
}
// ─── Formatting Helpers ───────────────────────────────────────────────────────
function formatRelativeTime(isoTimestamp: string): string {
try {
const dt = new Date(isoTimestamp);
const now = new Date();
const seconds = (now.getTime() - dt.getTime()) / 1000;
const minutes = seconds / 60;
const hours = seconds / 3600;
const days = seconds / 86400;
if (minutes < 1) return "just now";
if (minutes < 60) return `Math.floor(minutes)m ago`;
if (hours < 24) return `Math.floor(hours)h ago`;
if (days < 7) return `Math.floor(days)d ago`;
const month = dt.toLocaleString("en", { month: "short" });
if (dt.getFullYear() === now.getFullYear()) {
return `dt.getDate() month`;
}
return `dt.getDate() month, dt.getFullYear()`;
} catch {
return "";
}
}
function formatAge(createdAt: string): string {
const days = (Date.now() - new Date(createdAt).getTime()) / (24 * 60 * 60 * 1000);
if (days < 1) return "today";
if (days < 7) return `Math.floor(days)d ago`;
if (days < 30) return `Math.floor(days / 7)w ago`;
return `Math.floor(days / 30)mo ago`;
}
function formatProfileSection(
profile: Awaited<ReturnType<LocalMemoryStore["buildProfile"]>>,
charBudget: number = 800,
): string {
const sections: string[] = [];
if (profile.entities.length > 0) {
sections.push(`## 👤 Entities\nprofile.entities.slice(0, 5).join(", ")`);
}
if (profile.preferences.length > 0) {
sections.push(`## ❤️ Prefs\nprofile.preferences.slice(0, 3).map(p => p.slice(0, 60)).join("; ")`);
}
if (profile.static.length > 0) {
sections.push(`## 📝 Facts\nprofile.static.slice(0, 3).map(f => f.slice(0, 80)).join(" | ")`);
}
if (profile.dynamic.length > 0) {
sections.push(`## 🔄 Recent\nprofile.dynamic.slice(0, 2).map(d => d.slice(0, 60)).join(" | ")`);
}
if (sections.length === 0) return "";
// Respect budget
let result = `<memory-profile>\nsections.join("\n\n")\n</memory-profile>`;
if (result.length > charBudget) {
result = result.slice(0, charBudget) + "...";
}
return result;
}
function formatMemorySection(
memories: Awaited<ReturnType<LocalMemoryStore["search"]>>,
contextBudget: number = 2000,
): string {
if (memories.length === 0) return "";
// STRICT LIMITING: Cap at 3 most important memories to save context
const limited = memories.slice(0, 3);
const lines = limited.map((r) => {
const cat = r.metadata?.category ?? "other";
const age = formatAge(r.metadata?.createdAt ?? "");
const score = Math.round(r.score * 100);
// TRUNCATE HEAVILY to save context - max 150 chars per memory
let content = r.content;
if (content.length > 150) {
content = content.slice(0, 150) + "...";
}
return `[cat·score%·age] content`;
});
const joined = lines.join("\n");
// Respect budget
const truncated = joined.length > contextBudget ? joined.slice(0, contextBudget) + "..." : joined;
return `<memory-recall>\n## 🧠 Memories (limited.length/memories.length)\ntruncated\n</memory-recall>`;
}
function formatRecentSection(
memories: Awaited<ReturnType<LocalMemoryStore["getRecent"]>>,
contextBudget: number = 1500,
): string {
if (memories.length === 0) return "";
// STRICT: Only 2 recent memories max
const limited = memories.slice(0, 2);
const lines = limited.map((m) => {
const cat = m.metadata.category;
const age = formatAge(m.metadata.createdAt);
// TRUNCATE to 120 chars
let content = m.content;
if (content.length > 120) {
content = content.slice(0, 120) + "...";
}
return `[cat·age] content`;
});
const joined = lines.join("\n");
const truncated = joined.length > contextBudget ? joined.slice(0, contextBudget) + "..." : joined;
return `<memory-recent>\n## 🕐 Recent (limited.length)\ntruncated\n</memory-recent>`;
}
// ─── Token Estimation ────────────────────────────────────────────────────────
function estimateTokens(event: Record<string, unknown>): number {
let total = 0;
if (typeof event.prompt === "string") {
total += event.prompt.length;
}
if (Array.isArray(event.messages)) {
for (const msg of event.messages as Record<string, unknown>[]) {
const content = msg.content;
if (typeof content === "string") {
total += content.length;
} else if (Array.isArray(content)) {
for (const c of content) {
if (typeof c === "string") total += c.length;
else if (typeof c === "object" && c !== null) total += (c as Record<string, unknown>).text?.length ?? 0;
}
}
}
}
return Math.floor(total / 4); // Rough German/English average
}
// ─── Message Extraction ──────────────────────────────────────────────────────
function extractMessages(event: Record<string, unknown>): (string | Record<string, unknown>)[] {
if (Array.isArray(event.messages)) {
return event.messages as (string | Record<string, unknown>)[];
}
if (Array.isArray(event.content)) {
return event.content as (string | Record<string, unknown>)[];
}
return [];
}
function extractPrompt(event: Record<string, unknown>): string {
if (typeof event.prompt === "string") return event.prompt;
if (typeof event.messages === "string") return event.messages;
if (Array.isArray(event.messages)) {
for (const msg of event.messages as Record<string, unknown>[]) {
if (msg.role === "user") {
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map((c) => (typeof c === "string" ? c : (c as Record<string, unknown>).text ?? "")).join(" ");
}
}
}
}
return "";
}
function extractAssistantResponse(event: Record<string, unknown>): string {
const messages = extractMessages(event);
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (typeof msg === "object" && msg !== null && (msg as Record<string, unknown>).role === "assistant") {
const content = (msg as Record<string, unknown>).content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map((c) => (typeof c === "string" ? c : (c as Record<string, unknown>).text ?? "")).join(" ");
}
}
}
return "";
}
FILE:lib/store.ts
/**
* LocalMemoryStore — Advanced brain-like memory store
*
* Features:
* - Hierarchical memory (working/short-term/long-term)
* - Entity extraction and tracking
* - Importance scoring with decay
* - Semantic chunking for long content
* - Cross-session learning
* - Memory importance based on recency, relevance, and usage
* - Context-aware retrieval
*/
import { randomUUID } from "node:crypto";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MemoryEntry {
id: string;
content: string;
tfidf: Record<string, number>;
importance: number; // 0-1, calculated from multiple factors
accessCount: number; // How many times this was retrieved
lastAccessed: string; // ISO timestamp
accessHistory: string[]; // Recent access timestamps
// Brain-like structure
chunkOf?: string; // Parent chunk if this is a chunk
chunks?: string[]; // Child chunk IDs if this is a summary
metadata: {
sessionKey?: string;
category: "preference" | "fact" | "decision" | "entity" | "skill" | "context" | "other";
createdAt: string;
updatedAt: string;
accessedAt: string;
source?: "user" | "assistant" | "system";
messageType?: "exchange" | "summary" | "entity" | "preference" | "fact";
// Entity tracking
entities?: string[]; // Extracted entities (names, places, etc.)
tags?: string[]; // Semantic tags
// Memory links (IDs of related memories)
links?: string[];
// Composite key for deduplication
contentHash?: string;
};
}
export interface SearchResult {
id: string;
content: string;
similarity: number;
importance: number;
recency: number; // 0-1, newer = higher
metadata: MemoryEntry["metadata"];
score: number; // Combined relevance score
}
export interface StoreConfig {
containerTag: string;
debug: boolean;
maxMemories?: number;
pruneOlderThanDays?: number;
// Brain settings
decayRate?: number; // How fast importance decays (0-1)
chunkThreshold?: number; // Min chars to trigger chunking
chunkSize?: number; // Target chars per chunk
maxChunks?: number; // Max chunks per summary
// Recall settings
importanceWeight?: number; // Weight for importance in scoring
recencyWeight?: number; // Weight for recency in scoring
relevanceWeight?: number; // Weight for TF-IDF relevance
}
interface MemoryStats {
totalMemories: number;
totalChunks: number;
avgImportance: number;
categoryBreakdown: Record<string, number>;
lastCleanup: string;
}
// ─── Tokenizer & TF-IDF ───────────────────────────────────────────────────────
/** Enhanced tokenizer that preserves important patterns */
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^a-zäöüß0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2);
}
/** Extract entities (names, emails, URLs, etc.) */
function extractEntities(text: string): string[] {
const entities: string[] = [];
// Emails
const emails = text.match(/[\w.-]+@[\w.-]+\.\w+/g);
if (emails) entities.push(...emails);
// URLs
const urls = text.match(/https?:\/\/[^\s]+/g);
if (urls) entities.push(...urls);
// Capitalized words (potential names/entities)
const caps = text.match(/[A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)*/g);
if (caps) entities.push(...caps.slice(0, 5));
// Hashtags and mentions
const hashtags = text.match(/[#@][\w]+/g);
if (hashtags) entities.push(...hashtags);
return [...new Set(entities)];
}
/** Extract semantic tags from content */
function extractTags(text: string): string[] {
const tags: string[] = [];
const lower = text.toLowerCase();
// Domain-specific tags
const tagMap: Record<string, string[]> = {
"code|programming|script|function|api": ["coding", "development"],
"email|mail|send|outlook": ["email", "communication"],
"file|document|folder|directory": ["files", "organization"],
"server|hosting|deploy|ssh|plesk": ["infrastructure", "server"],
"memory|brain|remember|forget": ["memory", "cognition"],
"task|todo|project|deadline": ["tasks", "productivity"],
"money|cost|price|budget|revenue": ["finance", "business"],
"meeting|call|calendar|schedule": ["calendar", "coordination"],
"password|security|auth|login": ["security", "credentials"],
"error|bug|issue|problem": ["debugging", "issues"],
};
for (const [pattern, tagList] of Object.entries(tagMap)) {
if (new RegExp(pattern).test(lower)) {
tags.push(...tagList);
}
}
return [...new Set(tags)].slice(0, 5);
}
function computeTF(tokens: string[]): Record<string, number> {
const tf: Record<string, number> = {};
for (const token of tokens) {
tf[token] = (tf[token] ?? 0) + 1;
}
const len = tokens.length;
for (const token in tf) {
tf[token] /= len;
}
return tf;
}
function computeIDF(documents: string[][]): Record<string, number> {
const idf: Record<string, number> = {};
const N = documents.length;
const docFreq: Record<string, number> = {};
for (const doc of documents) {
const seen = new Set(doc);
for (const term of seen) {
docFreq[term] = (docFreq[term] ?? 0) + 1;
}
}
for (const term in docFreq) {
idf[term] = Math.log((N + 1) / (docFreq[term] + 1)) + 1;
}
return idf;
}
function computeTFIDF(tf: Record<string, number>, idf: Record<string, number>): Record<string, number> {
const vec: Record<string, number> = {};
for (const term in tf) {
vec[term] = tf[term] * (idf[term] ?? 1);
}
return vec;
}
function cosineSimilarity(a: Record<string, number>, b: Record<string, number>): number {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
let dot = 0, normA = 0, normB = 0;
for (const k of keys) {
const av = a[k] ?? 0;
const bv = b[k] ?? 0;
dot += av * bv;
normA += av * av;
normB += bv * bv;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8);
}
// ─── Content Hashing for Deduplication ───────────────────────────────────────
function hashContent(content: string): string {
let hash = 0;
const normalized = content.toLowerCase().replace(/\s+/g, ' ').trim();
for (let i = 0; i < normalized.length; i++) {
const char = normalized.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
/** Sanitize container tag to prevent path traversal */
function sanitizeContainerTag(tag: string): string {
return tag
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 64); // Max 64 chars
}
// ─── Category Detection ───────────────────────────────────────────────────────
function detectCategory(text: string): MemoryEntry["metadata"]["category"] {
const lower = text.toLowerCase();
if (/\b(immer|nur|nie|bevorzug|präferiert|like|love|hate|prefer|want|need|never|always)\b/i.test(lower)) return "preference";
if (/\b(entschieden|beschlossen|geplant|wird|werden|machen|setup|installiert|aktiviert|configured|decided|will use|going with|chose|selected)\b/i.test(lower)) return "decision";
if (/\b(skill|können|fähig|ability|experienced|proficient|know how)\b/i.test(lower)) return "skill";
if (/\b(ich bin|mein|unser|Name|Email|Konto|team|firma|unternehmen|company|name|called|named)\b/i.test(lower)) return "entity";
if (/\b(ist|sind|hat|haben|war|waren|is|are|has|have|was|were)\b/i.test(lower) && text.length < 500) return "fact";
return "other";
}
// ─── Importance Scoring ───────────────────────────────────────────────────────
/**
* Calculate importance based on multiple factors:
* - Content length (longer = more important up to a point)
* - Entity count (more entities = more important)
* - Category (decisions and preferences score higher)
* - User vs assistant (user content scores higher)
*/
function calculateImportance(
content: string,
metadata: MemoryEntry["metadata"],
accessCount: number,
): number {
let score = 0.3; // Base score
// Length factor (optimal range: 100-1000 chars)
const len = content.length;
if (len > 100 && len < 1000) score += 0.2;
else if (len >= 1000 && len < 5000) score += 0.15;
else if (len >= 5000) score += 0.1;
else if (len < 50) score -= 0.1;
// Entity factor
const entityCount = (metadata.entities?.length ?? 0);
score += Math.min(entityCount * 0.05, 0.2);
// Category factor
if (metadata.category === "decision") score += 0.25;
else if (metadata.category === "preference") score += 0.2;
else if (metadata.category === "entity") score += 0.15;
else if (metadata.category === "skill") score += 0.2;
// Source factor (user content is more important)
if (metadata.source === "user") score += 0.15;
// Access factor (memories that have been accessed multiple times are valuable)
score += Math.min(accessCount * 0.02, 0.15);
return Math.max(0.1, Math.min(1, score));
}
/**
* Apply time-based decay to importance
*/
function applyDecay(importance: number, createdAt: string, decayRate: number): number {
const ageMs = Date.now() - new Date(createdAt).getTime();
const ageDays = ageMs / (24 * 60 * 60 * 1000);
const decay = Math.exp(-decayRate * ageDays);
return importance * decay;
}
// ─── Semantic Chunker ─────────────────────────────────────────────────────────
/**
* Split long content into meaningful chunks
*/
function chunkContent(content: string, chunkSize: number, maxChunks: number): string[] {
if (content.length <= chunkSize) return [content];
const chunks: string[] = [];
const sentences = content.match(/[^.!?]+[.!?]+/g) || [content];
let currentChunk = "";
for (const sentence of sentences) {
if (currentChunk.length + sentence.length > chunkSize && currentChunk.length > 0) {
chunks.push(currentChunk.trim());
if (chunks.length >= maxChunks) break;
currentChunk = sentence;
} else {
currentChunk += " " + sentence;
}
}
if (chunks.length < maxChunks && currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
}
// ─── Store ───────────────────────────────────────────────────────────────────
export class LocalMemoryStore {
private memories: MemoryEntry[] = [];
private idf: Record<string, number> = {};
private dirty = false;
public readonly containerTag: string;
private storePath: string;
// Config
private maxMemories: number;
private pruneOlderThanDays: number;
private decayRate: number;
private chunkThreshold: number;
private chunkSize: number;
private maxChunks: number;
private importanceWeight: number;
private recencyWeight: number;
private relevanceWeight: number;
// Runtime stats
private stats: MemoryStats = {
totalMemories: 0,
totalChunks: 0,
avgImportance: 0.5,
categoryBreakdown: {},
lastCleanup: new Date().toISOString(),
};
constructor(cfg: StoreConfig) {
this.containerTag = sanitizeContainerTag(cfg.containerTag);
this.maxMemories = cfg.maxMemories ?? 500;
this.pruneOlderThanDays = cfg.pruneOlderThanDays ?? 30;
this.decayRate = cfg.decayRate ?? 0.05;
this.chunkThreshold = cfg.chunkThreshold ?? 2000;
this.chunkSize = cfg.chunkSize ?? 800;
this.maxChunks = cfg.maxChunks ?? 3;
this.importanceWeight = cfg.importanceWeight ?? 0.25;
this.recencyWeight = cfg.recencyWeight ?? 0.25;
this.relevanceWeight = cfg.relevanceWeight ?? 0.5;
this.storePath = resolve(homedir(), ".openclaw", "memory", `this.containerTag.json`);
this.load();
}
// ── Persistence ─────────────────────────────────────────────────────────
private load() {
try {
const dir = resolve(homedir(), ".openclaw", "memory");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
if (existsSync(this.storePath)) {
const raw = readFileSync(this.storePath, "utf-8");
const data = JSON.parse(raw);
this.memories = Array.isArray(data.memories) ? data.memories : [];
this.stats = data.stats ?? this.stats;
this.rebuildIDF();
}
} catch {
this.memories = [];
}
}
private save() {
if (!this.dirty) return;
try {
const dir = resolve(homedir(), ".openclaw", "memory");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
// Prune before saving
this.prune();
const data = {
memories: this.memories,
stats: this.stats,
version: "0.4.0",
};
writeFileSync(this.storePath, JSON.stringify(data, null, 2), "utf-8");
this.dirty = false;
} catch (err) {
console.error("[local-memory] save failed:", err);
}
}
private rebuildIDF() {
const docs = this.memories.map((m) => tokenize(m.content));
this.idf = computeIDF(docs);
}
// ── Pruning ────────────────────────────────────────────────────────────
private prune() {
const now = Date.now();
const maxAge = this.pruneOlderThanDays * 24 * 60 * 60 * 1000;
// Filter out old memories (but keep high-importance ones longer)
const beforePrune = this.memories.length;
this.memories = this.memories.filter((m) => {
const age = now - new Date(m.metadata.createdAt).getTime();
// Keep if: not old OR very important
if (age < maxAge) return true;
if (m.importance > 0.7) return true; // Protect important memories
return false;
});
// If still over limit, remove lowest importance
if (this.memories.length > this.maxMemories) {
// Sort by importance (with decay applied)
const now = Date.now();
this.memories.sort((a, b) => {
const ageA = (now - new Date(a.metadata.createdAt).getTime()) / (24 * 60 * 60 * 1000);
const ageB = (now - new Date(b.metadata.createdAt).getTime()) / (24 * 60 * 60 * 1000);
const scoreA = a.importance * Math.exp(-this.decayRate * ageA);
const scoreB = b.importance * Math.exp(-this.decayRate * ageB);
return scoreB - scoreA;
});
// Keep only top maxMemories
const removed = this.memories.splice(this.maxMemories);
// Also remove orphaned chunks
const keptIds = new Set(this.memories.map(m => m.id));
this.memories = this.memories.filter(m =>
!m.chunkOf || keptIds.has(m.chunkOf)
);
}
const pruned = beforePrune - this.memories.length;
if (pruned > 0) {
console.log(`[local-memory] pruned pruned memories (this.memories.length remaining)`);
this.rebuildIDF();
this.updateStats();
}
this.stats.lastCleanup = new Date().toISOString();
}
private updateStats() {
this.stats.totalMemories = this.memories.length;
this.stats.totalChunks = this.memories.filter(m => !!m.chunkOf).length;
const categories: Record<string, number> = {};
let totalImp = 0;
for (const m of this.memories) {
const cat = m.metadata.category;
categories[cat] = (categories[cat] ?? 0) + 1;
totalImp += m.importance;
}
this.stats.avgImportance = totalImp / this.memories.length;
this.stats.categoryBreakdown = categories;
}
// ── CRUD ──────────────────────────────────────────────────────────────
async add(
content: string,
metadata: Partial<MemoryEntry["metadata"]> = {},
): Promise<string> {
// Check for duplicate
const contentHash = hashContent(content);
const existing = this.memories.find(m => m.metadata.contentHash === contentHash);
if (existing) {
// Update access and bump importance slightly
existing.accessCount++;
existing.lastAccessed = new Date().toISOString();
existing.accessHistory.push(new Date().toISOString());
existing.metadata.accessedAt = new Date().toISOString();
this.dirty = true;
return existing.id;
}
// Chunk if too long
const chunks = chunkContent(content, this.chunkSize, this.maxChunks);
const isChunked = chunks.length > 1;
const tokens = tokenize(chunks[0]); // Use first chunk for TF-IDF
const tf = computeTF(tokens);
const tfidf = computeTFIDF(tf, this.idf);
// Extract entities and tags from full content
const entities = metadata.entities ?? extractEntities(content);
const tags = metadata.tags ?? extractTags(content);
const category = metadata.category ?? detectCategory(content);
const now = new Date().toISOString();
const id = randomUUID();
const entry: MemoryEntry = {
id,
content: chunks[0], // Store first chunk as main content
tfidf,
importance: calculateImportance(content, { ...metadata, category }, 0),
accessCount: 0,
lastAccessed: now,
accessHistory: [],
metadata: {
category,
createdAt: now,
updatedAt: now,
accessedAt: now,
sessionKey: metadata.sessionKey,
source: metadata.source,
messageType: metadata.messageType,
entities,
tags,
contentHash,
links: metadata.links ?? [],
},
};
this.memories.push(entry);
// Create child chunks if chunked
if (isChunked && chunks.length > 1) {
entry.chunks = [];
for (let i = 1; i < chunks.length; i++) {
const chunkId = await this.addChunk(chunks[i], id, category, metadata);
entry.chunks.push(chunkId);
}
}
// Update IDF incrementally
const newDocs = [tokens];
const newIDF = computeIDF(newDocs);
for (const term in newIDF) {
this.idf[term] = newIDF[term];
}
this.dirty = true;
this.updateStats();
this.save();
return id;
}
private async addChunk(
content: string,
parentId: string,
category: MemoryEntry["metadata"]["category"],
metadata: Partial<MemoryEntry["metadata"]>,
): Promise<string> {
const tokens = tokenize(content);
const tf = computeTF(tokens);
const tfidf = computeTFIDF(tf, this.idf);
const now = new Date().toISOString();
const id = randomUUID();
const chunkEntry: MemoryEntry = {
id,
content,
tfidf,
importance: 0.3, // Chunks have lower base importance
accessCount: 0,
lastAccessed: now,
accessHistory: [],
chunkOf: parentId,
metadata: {
category,
createdAt: now,
updatedAt: now,
accessedAt: now,
sessionKey: metadata.sessionKey,
source: metadata.source,
entities: extractEntities(content),
tags: extractTags(content),
},
};
this.memories.push(chunkEntry);
return id;
}
/**
* Enhanced search with brain-like scoring:
* - TF-IDF relevance
* - Importance (with decay)
* - Recency
* - Access frequency
*/
async search(
query: string,
limit = 10,
threshold = 0.1,
): Promise<SearchResult[]> {
const queryTokens = tokenize(query);
const queryTF = computeTF(queryTokens);
const queryTFIDF = computeTFIDF(queryTF, this.idf);
const now = Date.now();
const scored = this.memories
.filter(m => !m.chunkOf) // Don't return chunks directly
.map((entry) => {
const relevance = cosineSimilarity(queryTFIDF, entry.tfidf);
// Get all chunks for this memory
const allContent = [entry.content];
if (entry.chunks) {
for (const chunkId of entry.chunks) {
const chunk = this.memories.find(m => m.id === chunkId);
if (chunk) allContent.push(chunk.content);
}
}
// Recalculate relevance across all content
const fullTfidf = computeTFIDF(computeTF(tokenize(allContent.join(" "))), this.idf);
const fullRelevance = cosineSimilarity(queryTFIDF, fullTfidf);
// Time since last access (in days)
const lastAccessAge = (now - new Date(entry.lastAccessed).getTime()) / (24 * 60 * 60 * 1000);
const recency = Math.exp(-0.1 * lastAccessAge); // Exponential decay
// Time since creation
const age = (now - new Date(entry.metadata.createdAt).getTime()) / (24 * 60 * 60 * 1000);
const ageDecay = Math.exp(-this.decayRate * age);
// Importance with decay
const decayedImportance = entry.importance * ageDecay;
// Access frequency boost
const accessBoost = Math.min(entry.accessCount * 0.01, 0.2);
// Combined score
const score =
(this.relevanceWeight * fullRelevance) +
(this.importanceWeight * (decayedImportance + accessBoost)) +
(this.recencyWeight * recency);
// Update access tracking
entry.accessCount++;
entry.lastAccessed = new Date().toISOString();
entry.accessHistory.push(new Date().toISOString());
if (entry.accessHistory.length > 10) {
entry.accessHistory = entry.accessHistory.slice(-10);
}
entry.metadata.accessedAt = new Date().toISOString();
return {
id: entry.id,
content: allContent.join("\n\n"),
similarity: fullRelevance,
importance: decayedImportance,
recency,
metadata: entry.metadata,
score,
};
});
const filtered = scored
.filter((s) => s.score >= threshold)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
this.dirty = true;
return filtered;
}
/**
* Get memories by category
*/
async getByCategory(category: MemoryEntry["metadata"]["category"]): Promise<MemoryEntry[]> {
return this.memories.filter(m => m.metadata.category === category && !m.chunkOf);
}
/**
* Get entities (people, places, things we know about)
*/
async getEntities(): Promise<MemoryEntry[]> {
return this.memories.filter(m => m.metadata.category === "entity" && !m.chunkOf);
}
/**
* Get preferences (user likes, dislikes, habits)
*/
async getPreferences(): Promise<MemoryEntry[]> {
return this.memories.filter(m => m.metadata.category === "preference" && !m.chunkOf);
}
/**
* Get recent memories
*/
async getRecent(limit = 10): Promise<MemoryEntry[]> {
return [...this.memories]
.filter(m => !m.chunkOf)
.sort((a, b) => new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime())
.slice(0, limit);
}
/**
* Get frequently accessed memories
*/
async getFrequent(limit = 10): Promise<MemoryEntry[]> {
return [...this.memories]
.filter(m => !m.chunkOf)
.sort((a, b) => b.accessCount - a.accessCount)
.slice(0, limit);
}
async delete(id: string): Promise<void> {
const entry = this.memories.find(m => m.id === id);
if (!entry) return;
// Also delete child chunks
if (entry.chunks) {
for (const chunkId of entry.chunks) {
const idx = this.memories.findIndex(m => m.id === chunkId);
if (idx !== -1) this.memories.splice(idx, 1);
}
}
const idx = this.memories.findIndex(m => m.id === id);
if (idx !== -1) {
this.memories.splice(idx, 1);
this.dirty = true;
this.updateStats();
}
}
async forgetByQuery(
query: string,
limit = 1,
): Promise<{ success: boolean; message: string }> {
const results = await this.search(query, limit);
if (results.length === 0) {
return { success: false, message: "No matching memory found." };
}
await this.delete(results[0].id);
const preview = results[0].content.slice(0, 100);
return {
success: true,
message: `Forgot: "preview"""`,
};
}
async wipeAll(): Promise<{ deletedCount: number }> {
const count = this.memories.length;
this.memories = [];
this.idf = {};
this.dirty = true;
this.updateStats();
this.save();
return { deletedCount: count };
}
async count(): Promise<number> {
return this.memories.filter(m => !m.chunkOf).length;
}
async listAll(limit = 100): Promise<MemoryEntry[]> {
return [...this.memories]
.filter(m => !m.chunkOf)
.sort(
(a, b) =>
new Date(b.metadata.accessedAt).getTime() -
new Date(a.metadata.accessedAt).getTime()
)
.slice(0, limit);
}
async getStats(): Promise<MemoryStats> {
return { ...this.stats };
}
/**
* Build a user profile from memory (like Supermemory's profile system)
*/
async buildProfile(): Promise<{
static: string[];
dynamic: string[];
entities: string[];
preferences: string[];
}> {
const entities = new Set<string>();
const preferences = new Set<string>();
const staticFacts: string[] = [];
const dynamicFacts: string[] = [];
for (const m of this.memories) {
if (m.chunkOf) continue;
// Collect entities
if (m.metadata.entities) {
for (const e of m.metadata.entities) {
entities.add(e);
}
}
// Collect by category
if (m.metadata.category === "entity") {
staticFacts.push(m.content);
} else if (m.metadata.category === "preference") {
preferences.add(m.content);
} else if (m.metadata.category === "fact") {
// Recent facts are dynamic, old facts become static
const age = Date.now() - new Date(m.metadata.createdAt).getTime();
if (age < 7 * 24 * 60 * 60 * 1000) { // Less than 7 days old
dynamicFacts.push(m.content);
} else {
staticFacts.push(m.content);
}
}
}
return {
static: [...new Set(staticFacts)].slice(0, 20),
dynamic: [...new Set(dynamicFacts)].slice(0, 10),
entities: [...entities].slice(0, 20),
preferences: [...preferences].slice(0, 20),
};
}
/**
* Link related memories together
*/
async linkMemories(id1: string, id2: string): Promise<void> {
const m1 = this.memories.find(m => m.id === id1);
const m2 = this.memories.find(m => m.id === id2);
if (!m1 || !m2) return;
if (!m1.metadata.links) m1.metadata.links = [];
if (!m2.metadata.links) m2.metadata.links = [];
if (!m1.metadata.links.includes(id2)) m1.metadata.links.push(id2);
if (!m2.metadata.links.includes(id1)) m2.metadata.links.push(id1);
this.dirty = true;
}
/**
* Get related memories (via links or shared entities)
*/
async getRelated(memoryId: string, limit = 5): Promise<SearchResult[]> {
const memory = this.memories.find(m => m.id === memoryId);
if (!memory) return [];
// Get IDs of directly linked memories
const linkedIds = memory.metadata.links ?? [];
// Get memories with shared entities
const sharedEntityIds: string[] = [];
if (memory.metadata.entities) {
for (const entity of memory.metadata.entities) {
for (const m of this.memories) {
if (m.id !== memoryId && m.metadata.entities?.includes(entity)) {
sharedEntityIds.push(m.id);
}
}
}
}
// Combine and fetch
const allIds = [...new Set([...linkedIds, ...sharedEntityIds])].slice(0, limit);
const now = Date.now();
return allIds.map(id => {
const m = this.memories.find(x => x.id === id)!;
return {
id: m.id,
content: m.content,
similarity: 0.5,
importance: m.importance,
recency: Math.exp(-0.1 * (now - new Date(m.lastAccessed).getTime()) / (24 * 60 * 60 * 1000)),
metadata: m.metadata,
score: 0.5,
};
});
}
}
FILE:lib/tools.ts
/**
* Local Memory Tools
*
* Provides tools for:
* - Search: Find relevant memories
* - Store: Save a specific memory
* - List: View all memories
* - Forget: Delete a memory
* - Wipe: Delete all memories
* - Profile: View user profile built from memory
* - Stats: View memory statistics
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime.js";
import type { LocalMemoryStore } from "./store.js";
interface LocalMemoryConfig {
autoRecall?: boolean;
autoCapture?: boolean;
maxRecallResults?: number;
similarityThreshold?: number;
debug?: boolean;
captureInterval?: number;
captureSignificantOnly?: boolean;
pruneAfterCapture?: boolean;
maxMemories?: number;
pruneOlderThanDays?: number;
}
type LogFn = (level: "info" | "warn" | "debug", msg: string, data?: Record<string, unknown>) => void;
// ─── Helper: Format Memory Line ───────────────────────────────────────────────
function formatMemoryLine(r: { content: string; similarity: number; importance: number; metadata: Record<string, unknown> }): string {
const cat = r.metadata?.category ?? "other";
const sim = Math.round(r.similarity * 100);
const imp = Math.round(r.importance * 100);
const age = formatAge(r.metadata?.createdAt as string ?? new Date().toISOString());
const content = r.content.length > 400 ? r.content.slice(0, 400) + "..." : r.content;
return `[cat·R:sim%·I:imp%·age]\ncontent`;
}
function formatAge(isoTimestamp: string): string {
try {
const dt = new Date(isoTimestamp);
const now = new Date();
const days = (now.getTime() - dt.getTime()) / (24 * 60 * 60 * 1000);
if (days < 1) return "today";
if (days < 7) return `Math.floor(days)d`;
if (days < 30) return `Math.floor(days / 7)w`;
return `Math.floor(days / 30)mo`;
} catch {
return "unknown";
}
}
// ─── Search Tool ─────────────────────────────────────────────────────────────
export function registerSearchTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
api.registerTool({
name: "local_memory_search",
description: "Search through long-term memories using semantic vector search. Returns memories ranked by relevance, importance, and recency.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "What to search for (natural language)" },
limit: { type: "number", description: "Max results (default 10)" },
threshold: { type: "number", description: "Min relevance score 0-1 (default 0.3)" },
},
required: ["query"],
},
async execute(_id, params) {
try {
const limit = params.limit ?? cfg.maxRecallResults ?? 10;
const threshold = params.threshold ?? cfg.similarityThreshold ?? 0.3;
const results = await store.search(params.query, limit, threshold);
if (results.length === 0) {
return { content: [{ type: "text", text: "No memories found matching that query." }] };
}
const lines = results.map(formatMemoryLine);
return {
content: [{
type: "text",
text: `Found results.length relevant memories:\n\nlines.join("\n\n---\n\n")`,
}],
};
} catch (err) {
log("warn", "search tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Search failed: String(err)` }] };
}
},
});
}
// ─── Store Tool ────────────────────────────────────────────────────────────────
export function registerStoreTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
api.registerTool({
name: "local_memory_store",
description: "Store a piece of information in long-term memory with automatic significance detection and categorization.",
parameters: {
type: "object",
properties: {
content: { type: "string", description: "What to remember" },
category: {
type: "string",
description: "Category: preference, fact, decision, entity, skill, context, or other (auto-detected if omitted)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Optional tags for this memory",
},
},
required: ["content"],
},
async execute(_id, params) {
try {
const validCategories = ["preference", "fact", "decision", "entity", "skill", "context", "other"];
const category = params.category && validCategories.includes(params.category)
? params.category as any
: undefined;
const id = await store.add(params.content, {
category,
tags: params.tags,
source: "user",
});
log("info", "memory stored via tool", { id });
return {
content: [{
type: "text",
text: `Stored in memory (id: id.slice(0, 8))category ? ` [${category]` : ""}`,
}],
};
} catch (err) {
log("warn", "store tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to store: String(err)` }] };
}
},
});
}
// ─── List Tool ───────────────────────────────────────────────────────────────
export function registerListTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
api.registerTool({
name: "local_memory_list",
description: "List all memories, optionally filtered by category. Shows most recently accessed first.",
parameters: {
type: "object",
properties: {
category: {
type: "string",
description: "Filter by category: preference, fact, decision, entity, skill, other",
},
limit: { type: "number", description: "Max results (default 20)" },
sort: {
type: "string",
description: "Sort by: recent, importance, frequent (default: recent)",
},
},
},
async execute(_id, params) {
try {
const limit = params.limit ?? 20;
let memories;
if (params.category) {
memories = await store.getByCategory(params.category as any);
} else if (params.sort === "importance") {
memories = await store.listAll(limit * 2);
memories.sort((a, b) => b.importance - a.importance);
memories = memories.slice(0, limit);
} else if (params.sort === "frequent") {
memories = await store.getFrequent(limit);
} else {
memories = await store.listAll(limit);
}
if (memories.length === 0) {
return { content: [{ type: "text", text: "No memories found." }] };
}
const lines = memories.map((m) => {
const cat = m.metadata.category;
const age = formatAge(m.metadata.createdAt);
const imp = Math.round(m.importance * 100);
const content = m.content.length > 200 ? m.content.slice(0, 200) + "..." : m.content;
return `[cat·imp%·age]\ncontent`;
});
return {
content: [{
type: "text",
text: `memories.length memories:\n\nlines.join("\n\n---\n\n")`,
}],
};
} catch (err) {
log("warn", "list tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to list: String(err)` }] };
}
},
});
}
// ─── Forget Tool ─────────────────────────────────────────────────────────────
export function registerForgetTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
cfg: LocalMemoryConfig,
log: LogFn,
) {
api.registerTool({
name: "local_memory_forget",
description: "Delete the most relevant memory matching a query. Use to remove outdated or incorrect memories.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Query to find memory to delete" },
limit: { type: "number", description: "Number of memories to delete (default 1)" },
},
required: ["query"],
},
async execute(_id, params) {
try {
const limit = params.limit ?? 1;
const result = await store.forgetByQuery(params.query, limit);
if (result.success) {
log("info", "memory forgotten via tool", { query: params.query });
}
return { content: [{ type: "text", text: result.message }] };
} catch (err) {
log("warn", "forget tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to forget: String(err)` }] };
}
},
});
}
// ─── Wipe Tool ─────────────────────────────────────────────────────────────
export function registerWipeTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
log: LogFn,
) {
api.registerTool({
name: "local_memory_wipe",
description: "Delete ALL memories. This is irreversible! Requires confirmation.",
parameters: {
type: "object",
properties: {
confirm: { type: "boolean", description: "Must be true to confirm wipe" },
},
required: ["confirm"],
},
async execute(_id, params) {
try {
if (!params.confirm) {
return { content: [{ type: "text", text: "Wipe cancelled. Set confirm=true to proceed." }] };
}
const result = await store.wipeAll();
log("info", "all memories wiped", { count: result.deletedCount });
return {
content: [{
type: "text",
text: `Wiped result.deletedCount memories. This cannot be undone.`,
}],
};
} catch (err) {
log("warn", "wipe tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to wipe: String(err)` }] };
}
},
});
}
// ─── Profile Tool ───────────────────────────────────────────────────────────
export function registerProfileTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
log: LogFn,
) {
api.registerTool({
name: "local_memory_profile",
description: "View the user profile built from memory - entities, preferences, facts, and recent context.",
parameters: {
type: "object",
properties: {},
},
async execute(_id, _params) {
try {
const profile = await store.buildProfile();
const sections: string[] = [];
if (profile.entities.length > 0) {
sections.push(`## 👤 Known Entities\nprofile.entities.map(e => `- ${e`).join("\n")}`);
}
if (profile.preferences.length > 0) {
sections.push(`## ❤️ Preferences\nprofile.preferences.map(p => `- ${p`).join("\n")}`);
}
if (profile.static.length > 0) {
sections.push(`## 📝 Key Facts\nprofile.static.map(f => `- ${f`).join("\n")}`);
}
if (profile.dynamic.length > 0) {
sections.push(`## 🔄 Recent Context\nprofile.dynamic.map(d => `- ${d`).join("\n")}`);
}
if (sections.length === 0) {
return { content: [{ type: "text", text: "No profile data yet. Memory is being built over time." }] };
}
return {
content: [{
type: "text",
text: `# 🧠 Memory Profile\n\nsections.join("\n\n")`,
}],
};
} catch (err) {
log("warn", "profile tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to get profile: String(err)` }] };
}
},
});
}
// ─── Stats Tool ─────────────────────────────────────────────────────────────
export function registerStatsTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
log: LogFn,
) {
api.registerTool({
name: "local_memory_stats",
description: "View memory statistics - total count, category breakdown, cleanup status.",
parameters: {
type: "object",
properties: {},
},
async execute(_id, _params) {
try {
const stats = await store.getStats();
const count = await store.count();
const categories = Object.entries(stats.categoryBreakdown)
.map(([cat, count]) => `cat: count`)
.join(", ");
return {
content: [{
type: "text",
text: `# 📊 Memory Statistics
- **Total Memories:** count
- **Chunks:** stats.totalChunks
- **Avg Importance:** Math.round(stats.avgImportance * 100)%
- **Categories:** categories || "none"
- **Last Cleanup:** "never"`,
}],
};
} catch (err) {
log("warn", "stats tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed to get stats: String(err)` }] };
}
},
});
}
// ─── Recent Tool ────────────────────────────────────────────────────────────
export function registerRecentTool(
api: OpenClawPluginApi,
store: LocalMemoryStore,
log: LogFn,
) {
api.registerTool({
name: "local_memory_recent",
description: "Get recently accessed memories.",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "Max results (default 5)" },
},
},
async execute(_id, params) {
try {
const limit = params.limit ?? 5;
const memories = await store.getRecent(limit);
if (memories.length === 0) {
return { content: [{ type: "text", text: "No recent memories." }] };
}
const lines = memories.map((m) => {
const cat = m.metadata.category;
const age = formatAge(m.metadata.createdAt);
const content = m.content.length > 200 ? m.content.slice(0, 200) + "..." : m.content;
return `[cat·age]\ncontent`;
});
return {
content: [{
type: "text",
text: `## 🕐 Recent Memories\n\nlines.join("\n\n---\n\n")`,
}],
};
} catch (err) {
log("warn", "recent tool failed", { error: String(err) });
return { content: [{ type: "text", text: `Failed: String(err)` }] };
}
},
});
}
FILE:openclaw.plugin.json
{
"id": "openclaw-local-memory",
"kind": "memory",
"uiHints": {
"containerTag": {
"label": "Memory Name",
"placeholder": "openclaw_local_memory",
"help": "Namespace for this memory store"
},
"autoRecall": {
"label": "Auto-Recall",
"help": "Inject relevant memories before every AI turn"
},
"autoCapture": {
"label": "Auto-Capture",
"help": "Automatically store conversation exchanges in memory"
},
"captureInterval": {
"label": "Capture Interval",
"placeholder": "5",
"help": "Capture every N turns (set 0 to disable periodic capture)"
},
"minSignificanceScore": {
"label": "Min Significance",
"placeholder": "0.4",
"help": "Only capture content with significance score >= this value (0-1)"
},
"profileFrequency": {
"label": "Profile Frequency",
"placeholder": "10",
"help": "Inject user profile every N turns"
},
"maxRecallResults": {
"label": "Max Recall Results",
"placeholder": "10",
"help": "Maximum memories injected per turn",
"advanced": true
},
"similarityThreshold": {
"label": "Relevance Threshold",
"placeholder": "0.3",
"help": "Minimum relevance score to inject (0-1)",
"advanced": true
},
"maxMemories": {
"label": "Max Memories",
"placeholder": "500",
"help": "Maximum memories to keep (older pruned first)",
"advanced": true
},
"pruneOlderThanDays": {
"label": "Prune After Days",
"placeholder": "30",
"help": "Auto-delete memories older than N days",
"advanced": true
},
"decayRate": {
"label": "Importance Decay",
"placeholder": "0.05",
"help": "How fast importance decays over time (higher = faster decay)",
"advanced": true
},
"debug": {
"label": "Debug Logging",
"help": "Enable verbose debug logs",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"containerTag": {
"type": "string",
"default": "openclaw_local_memory"
},
"autoRecall": {
"type": "boolean",
"default": true
},
"autoCapture": {
"type": "boolean",
"default": true
},
"maxRecallResults": {
"type": "number",
"minimum": 1,
"maximum": 50,
"default": 10
},
"similarityThreshold": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.3
},
"debug": {
"type": "boolean",
"default": false
},
"captureInterval": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 8,
"help": "Capture every N turns (higher = less storage, lower context)"
},
"summariseThreshold": {
"type": "number",
"minimum": 0,
"maximum": 500000,
"default": 60000,
"help": "Token count to trigger consolidation (LOWER = more often, saves context)"
},
"captureSignificantOnly": {
"type": "boolean",
"default": true,
"help": "Only capture truly significant content"
},
"pruneAfterCapture": {
"type": "boolean",
"default": true,
"help": "Signal context pruning after capturing"
},
"minSignificanceScore": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.5,
"help": "Min significance score to capture (HIGHER = stricter, less noise)"
},
"profileFrequency": {
"type": "number",
"minimum": 1,
"maximum": 100,
"default": 15,
"help": "Inject profile every N turns (HIGHER = less context)"
},
"includeProfileOnFirstTurn": {
"type": "boolean",
"default": true,
"help": "Include profile on first turn of conversation"
},
"maxMemories": {
"type": "number",
"minimum": 10,
"maximum": 10000,
"default": 500,
"help": "Maximum memories to keep (older pruned first)",
"advanced": true
},
"pruneOlderThanDays": {
"type": "number",
"minimum": 1,
"maximum": 365,
"default": 30,
"help": "Auto-delete memories older than N days",
"advanced": true
},
"decayRate": {
"type": "number",
"minimum": 0.001,
"maximum": 1,
"default": 0.05,
"help": "Importance decay rate (higher = faster decay)",
"advanced": true
},
"chunkThreshold": {
"type": "number",
"minimum": 500,
"maximum": 10000,
"default": 2000,
"help": "Min content length to trigger chunking",
"advanced": true
},
"chunkSize": {
"type": "number",
"minimum": 200,
"maximum": 5000,
"default": 1000,
"help": "Target size per chunk in characters",
"advanced": true
},
"maxChunks": {
"type": "number",
"minimum": 2,
"maximum": 20,
"default": 5,
"help": "Maximum chunks per memory",
"advanced": true
},
"importanceWeight": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.3,
"help": "Weight of importance in recall scoring",
"advanced": true
},
"recencyWeight": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.3,
"help": "Weight of recency in recall scoring",
"advanced": true
},
"relevanceWeight": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.4,
"help": "Weight of TF-IDF relevance in recall scoring",
"advanced": true
}
}
}
}
FILE:package.json
{
"name": "@local/openclaw-local-memory",
"version": "1.0.0",
"type": "module",
"description": "Local vector memory plugin for OpenClaw — no external service required",
"license": "MIT",
"openclaw": {
"extensions": ["./index.ts"]
},
"peerDependencies": {
"openclaw": ">=2026.1.29"
}
}