@persistio/openclaw-plugin 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { RecallBundle } from './client.js';
2
+ export declare function normalizeRecallQuery(text: string, maxChars: number): string;
3
+ export declare function extractTextFromMessage(message: unknown): string;
4
+ export declare function buildRecallQuery(event: {
5
+ prompt?: string;
6
+ messages?: unknown[];
7
+ }, maxChars: number): string;
8
+ export declare function buildMemoryBlock(bundle: RecallBundle | undefined, tokenBudget: number, relatedBundle?: RecallBundle): string;
@@ -0,0 +1,121 @@
1
+ const BUNDLE_SECTIONS = [
2
+ { key: 'global_user_rules', title: 'User Rules' },
3
+ { key: 'user_rules', title: 'User Rules' },
4
+ { key: 'user_preferences', title: 'User Preferences' },
5
+ { key: 'constraints', title: 'Constraints' },
6
+ { key: 'decisions', title: 'Decisions' },
7
+ { key: 'task_patterns', title: 'Task Patterns' },
8
+ { key: 'workflows', title: 'Workflows' },
9
+ { key: 'project', title: 'Project Memory' },
10
+ { key: 'system_facts', title: 'Facts' },
11
+ { key: 'domain_knowledge', title: 'Domain Knowledge' },
12
+ ];
13
+ export function normalizeRecallQuery(text, maxChars) {
14
+ const normalized = text.replace(/\s+/g, ' ').trim();
15
+ if (normalized.length <= maxChars)
16
+ return normalized;
17
+ return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
18
+ }
19
+ export function extractTextFromMessage(message) {
20
+ if (typeof message === 'string')
21
+ return message;
22
+ if (typeof message !== 'object' || message === null)
23
+ return '';
24
+ const record = message;
25
+ const content = record['content'];
26
+ if (typeof content === 'string')
27
+ return content;
28
+ if (!Array.isArray(content))
29
+ return '';
30
+ return content
31
+ .map((part) => {
32
+ if (typeof part === 'string')
33
+ return part;
34
+ if (typeof part !== 'object' || part === null)
35
+ return '';
36
+ const partRecord = part;
37
+ return partRecord['type'] === 'text' && typeof partRecord['text'] === 'string'
38
+ ? partRecord['text']
39
+ : '';
40
+ })
41
+ .filter(Boolean)
42
+ .join('\n');
43
+ }
44
+ export function buildRecallQuery(event, maxChars) {
45
+ const prompt = typeof event.prompt === 'string' ? event.prompt : '';
46
+ if (prompt.trim())
47
+ return normalizeRecallQuery(prompt, maxChars);
48
+ const latestUser = Array.isArray(event.messages)
49
+ ? findLatestRoleText(event.messages, 'user')
50
+ : '';
51
+ return normalizeRecallQuery(latestUser, maxChars);
52
+ }
53
+ export function buildMemoryBlock(bundle, tokenBudget, relatedBundle) {
54
+ if ((!bundle && !relatedBundle) || tokenBudget <= 0)
55
+ return '';
56
+ const lines = [
57
+ '## Persistio Memory',
58
+ 'Relevant durable memory:',
59
+ ];
60
+ let usedTokens = estimateTokens(lines.join('\n'));
61
+ for (const section of BUNDLE_SECTIONS) {
62
+ const items = uniqueStrings([
63
+ ...arrayStrings(bundle?.[section.key]),
64
+ ...arrayStrings(relatedBundle?.[section.key]),
65
+ ]);
66
+ for (const item of items) {
67
+ const line = `- ${truncateOneLine(item, 360)}`;
68
+ const lineTokens = estimateTokens(line);
69
+ if (usedTokens + lineTokens > tokenBudget) {
70
+ lines.push('', 'Use these only when relevant. Do not mention memory unless asked.');
71
+ return lines.join('\n');
72
+ }
73
+ lines.push(line);
74
+ usedTokens += lineTokens;
75
+ }
76
+ }
77
+ if (lines.length <= 2)
78
+ return '';
79
+ lines.push('', 'Use these only when relevant. Do not mention memory unless asked.');
80
+ return lines.join('\n');
81
+ }
82
+ function findLatestRoleText(messages, role) {
83
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
84
+ const message = messages[index];
85
+ if (typeof message !== 'object' || message === null)
86
+ continue;
87
+ const record = message;
88
+ if (record['role'] !== role)
89
+ continue;
90
+ const text = extractTextFromMessage(message).trim();
91
+ if (text)
92
+ return text;
93
+ }
94
+ return '';
95
+ }
96
+ function arrayStrings(value) {
97
+ return Array.isArray(value)
98
+ ? value.filter((item) => typeof item === 'string')
99
+ : [];
100
+ }
101
+ function uniqueStrings(value) {
102
+ const seen = new Set();
103
+ const out = [];
104
+ for (const item of value) {
105
+ const normalized = item.replace(/\s+/g, ' ').trim();
106
+ if (!normalized || seen.has(normalized))
107
+ continue;
108
+ seen.add(normalized);
109
+ out.push(normalized);
110
+ }
111
+ return out;
112
+ }
113
+ function truncateOneLine(text, maxChars) {
114
+ const normalized = text.replace(/\s+/g, ' ').trim();
115
+ if (normalized.length <= maxChars)
116
+ return normalized;
117
+ return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
118
+ }
119
+ function estimateTokens(text) {
120
+ return Math.ceil(text.length / 4);
121
+ }
@@ -1,28 +1,19 @@
1
1
  {
2
- "id": "openclaw-persistio",
3
- "name": "Persistio Memory",
4
- "description": "Persistent semantic memory for OpenClaw via Persistio",
5
- "version": "0.1.7",
2
+ "id": "openclaw-persistio-v2",
3
+ "name": "Persistio Memory v2",
4
+ "description": "OpenClaw-native long-term memory powered by Persistio",
5
+ "version": "0.2.0",
6
6
  "kind": "memory",
7
7
  "activation": {
8
8
  "onStartup": true
9
9
  },
10
10
  "contracts": {
11
11
  "tools": [
12
- "memory_search",
13
- "memory_add",
14
- "memory_delete",
15
- "memory_list"
12
+ "memory_recall",
13
+ "memory_store",
14
+ "memory_forget"
16
15
  ]
17
16
  },
18
- "toolMetadata": {
19
- "memory_delete": {
20
- "optional": true
21
- },
22
- "memory_list": {
23
- "optional": true
24
- }
25
- },
26
17
  "configSchema": {
27
18
  "type": "object",
28
19
  "additionalProperties": false,
@@ -33,24 +24,13 @@
33
24
  "apiKey": {
34
25
  "type": "string"
35
26
  },
36
- "tokenBudget": {
37
- "type": "number",
38
- "minimum": 1
39
- },
40
- "recallTopK": {
41
- "type": "number",
42
- "minimum": 1
27
+ "autoRecall": {
28
+ "type": "boolean"
43
29
  },
44
- "recallMinSimilarity": {
45
- "type": "number",
46
- "minimum": 0,
47
- "maximum": 1
30
+ "autoCapture": {
31
+ "type": "boolean"
48
32
  },
49
- "recallTimeout": {
50
- "type": "number",
51
- "minimum": 1
52
- },
53
- "ingest": {
33
+ "recall": {
54
34
  "type": "object",
55
35
  "additionalProperties": false,
56
36
  "properties": {
@@ -58,94 +38,70 @@
58
38
  "type": "number",
59
39
  "minimum": 1
60
40
  },
61
- "maxChunkChars": {
41
+ "maxResults": {
62
42
  "type": "number",
63
- "minimum": 256
43
+ "minimum": 1
64
44
  },
65
- "maxChunksPerTurn": {
45
+ "tokenBudget": {
66
46
  "type": "number",
67
47
  "minimum": 1
68
48
  },
69
- "skipSubagentSessions": {
49
+ "minSimilarity": {
50
+ "type": "number",
51
+ "minimum": 0,
52
+ "maximum": 1
53
+ },
54
+ "includePending": {
70
55
  "type": "boolean"
71
56
  },
72
- "user": {
73
- "type": "object",
74
- "additionalProperties": false,
75
- "properties": {
76
- "maxCharsPerMessage": {
77
- "type": "number",
78
- "minimum": 1
79
- }
80
- }
57
+ "includeRelated": {
58
+ "type": "boolean"
81
59
  },
82
- "agent": {
83
- "type": "object",
84
- "additionalProperties": false,
85
- "properties": {
86
- "mode": {
87
- "type": "string",
88
- "enum": [
89
- "bounded",
90
- "raw"
91
- ]
92
- },
93
- "maxCharsPerMessage": {
94
- "type": "number",
95
- "minimum": 1
96
- },
97
- "maxCharsAfterFiltering": {
98
- "type": "number",
99
- "minimum": 1
100
- },
101
- "maxCharsPerTurn": {
102
- "type": "number",
103
- "minimum": 1
104
- },
105
- "largeBlockThresholdChars": {
106
- "type": "number",
107
- "minimum": 1
108
- },
109
- "largeBlockThresholdLines": {
110
- "type": "number",
111
- "minimum": 1
112
- },
113
- "maxTableRows": {
114
- "type": "number",
115
- "minimum": 1
116
- }
117
- }
60
+ "queryMaxChars": {
61
+ "type": "number",
62
+ "minimum": 100
118
63
  }
119
64
  }
120
65
  },
121
- "send": {
66
+ "capture": {
122
67
  "type": "object",
123
68
  "additionalProperties": false,
124
69
  "properties": {
70
+ "timeoutMs": {
71
+ "type": "number",
72
+ "minimum": 1
73
+ },
74
+ "maxCharsPerTurn": {
75
+ "type": "number",
76
+ "minimum": 1
77
+ },
78
+ "maxCharsPerMessage": {
79
+ "type": "number",
80
+ "minimum": 1
81
+ },
82
+ "maxChunksPerTurn": {
83
+ "type": "number",
84
+ "minimum": 1
85
+ },
86
+ "maxChunkChars": {
87
+ "type": "number",
88
+ "minimum": 256
89
+ },
125
90
  "roles": {
126
91
  "type": "object",
127
92
  "additionalProperties": false,
128
93
  "properties": {
129
94
  "user": {
130
95
  "type": "string",
131
- "enum": [
132
- "enabled",
133
- "disabled"
134
- ]
96
+ "enum": ["enabled", "disabled"]
135
97
  },
136
- "agent": {
98
+ "assistant": {
137
99
  "type": "string",
138
- "enum": [
139
- "enabled",
140
- "disabled"
141
- ]
100
+ "enum": ["enabled", "bounded", "disabled"]
142
101
  },
143
102
  "tool": {
144
103
  "type": "string",
145
- "enum": [
146
- "enabled",
147
- "disabled"
148
- ]
104
+ "enum": ["enabled", "disabled"]
149
105
  }
150
106
  }
151
107
  }
@@ -156,5 +112,23 @@
156
112
  "baseURL",
157
113
  "apiKey"
158
114
  ]
115
+ },
116
+ "uiHints": {
117
+ "apiKey": {
118
+ "label": "Vault API Key",
119
+ "sensitive": true
120
+ },
121
+ "baseURL": {
122
+ "label": "Persistio Base URL",
123
+ "placeholder": "https://api.persistio.ai"
124
+ },
125
+ "autoRecall": {
126
+ "label": "Auto Recall",
127
+ "help": "Inject a small bounded Persistio memory block before each turn"
128
+ },
129
+ "autoCapture": {
130
+ "label": "Auto Capture",
131
+ "help": "Capture bounded durable conversation facts after successful turns"
132
+ }
159
133
  }
160
134
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@persistio/openclaw-plugin",
3
- "version": "0.1.7",
4
- "description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
3
+ "version": "0.2.0",
4
+ "description": "OpenClaw-native Persistio long-term memory plugin",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "exports": {
@@ -24,9 +24,8 @@
24
24
  "openclaw",
25
25
  "openclaw-plugin",
26
26
  "memory",
27
- "ai",
28
- "agents",
29
- "persistio"
27
+ "persistio",
28
+ "long-term-memory"
30
29
  ],
31
30
  "openclaw": {
32
31
  "extensions": [
@@ -36,22 +35,22 @@
36
35
  "./dist/index.js"
37
36
  ],
38
37
  "compat": {
39
- "pluginApi": ">=2026.3.24-beta.2",
40
- "minGatewayVersion": "2026.3.24-beta.2"
38
+ "pluginApi": ">=2026.4.10",
39
+ "minGatewayVersion": "2026.4.10"
41
40
  }
42
41
  },
43
42
  "scripts": {
44
43
  "build": "tsc",
45
- "test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs && node test/client-timeout.test.mjs"
44
+ "test": "npm run build && node test/config.test.mjs && node test/formatting.test.mjs && node test/client-timeout.test.mjs && node test/tool-timeout.test.mjs"
46
45
  },
47
46
  "dependencies": {
48
47
  "@sinclair/typebox": "^0.34.0"
49
48
  },
50
49
  "devDependencies": {
51
- "typescript": "^5.0.0",
52
- "@types/node": "^22.0.0"
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.0.0"
53
52
  },
54
53
  "peerDependencies": {
55
- "openclaw": ">=2026.3.24-beta.2"
54
+ "openclaw": ">=2026.4.10"
56
55
  }
57
56
  }
package/src/capture.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { IngestChunk } from './client.js';
2
+ import type { PersistioV2Config } from './config.js';
3
+ import { extractTextFromMessage } from './memory-format.js';
4
+
5
+ export interface PreparedCapture {
6
+ chunks: IngestChunk[];
7
+ keys: string[];
8
+ items: CaptureItem[];
9
+ }
10
+
11
+ export interface CaptureItem {
12
+ key: string;
13
+ chunks: IngestChunk[];
14
+ }
15
+
16
+ export interface PrepareCaptureOptions {
17
+ shouldIncludeKey?: (key: string) => boolean;
18
+ }
19
+
20
+ export function prepareCapture(
21
+ event: { messages?: unknown[] },
22
+ cfg: PersistioV2Config,
23
+ options: PrepareCaptureOptions = {},
24
+ ): PreparedCapture {
25
+ if (!Array.isArray(event.messages)) return { chunks: [], keys: [], items: [] };
26
+
27
+ const chunks: IngestChunk[] = [];
28
+ const keys: string[] = [];
29
+ const items: CaptureItem[] = [];
30
+ let turnChars = 0;
31
+
32
+ for (const [index, message] of event.messages.entries()) {
33
+ const role = normalizeRole(message);
34
+ if (!role || !shouldCaptureRole(role, cfg)) continue;
35
+
36
+ const rawText = extractTextFromMessage(message);
37
+ const key = messageKey(message, role, rawText, index);
38
+ if (options.shouldIncludeKey && !options.shouldIncludeKey(key)) continue;
39
+
40
+ if (chunks.length >= cfg.capture.maxChunksPerTurn) break;
41
+ const preparedText = prepareTextForRole(rawText, role, cfg);
42
+ if (!preparedText) continue;
43
+
44
+ const remainingTurnChars = cfg.capture.maxCharsPerTurn - turnChars;
45
+ if (remainingTurnChars <= 0) break;
46
+
47
+ const boundedText = truncate(preparedText, Math.min(cfg.capture.maxCharsPerMessage, remainingTurnChars));
48
+ const itemChunks: IngestChunk[] = [];
49
+ for (const chunk of chunkText(boundedText, cfg.capture.maxChunkChars)) {
50
+ if (chunks.length >= cfg.capture.maxChunksPerTurn) break;
51
+ const ingestChunk = {
52
+ role,
53
+ content: chunk,
54
+ timestamp: resolveTimestamp(message) ?? new Date().toISOString(),
55
+ };
56
+ chunks.push(ingestChunk);
57
+ itemChunks.push(ingestChunk);
58
+ turnChars += chunk.length;
59
+ }
60
+ if (itemChunks.length > 0) {
61
+ keys.push(key);
62
+ items.push({ key, chunks: itemChunks });
63
+ }
64
+ }
65
+
66
+ return { chunks, keys, items };
67
+ }
68
+
69
+ function normalizeRole(message: unknown): 'user' | 'assistant' | 'tool' | null {
70
+ if (typeof message !== 'object' || message === null) return null;
71
+ const role = (message as Record<string, unknown>)['role'];
72
+ return role === 'user' || role === 'assistant' || role === 'tool' ? role : null;
73
+ }
74
+
75
+ function shouldCaptureRole(role: 'user' | 'assistant' | 'tool', cfg: PersistioV2Config): boolean {
76
+ if (role === 'user') return cfg.capture.roles.user === 'enabled';
77
+ if (role === 'assistant') return cfg.capture.roles.assistant !== 'disabled';
78
+ return cfg.capture.roles.tool === 'enabled';
79
+ }
80
+
81
+ function prepareTextForRole(text: string, role: 'user' | 'assistant' | 'tool', cfg: PersistioV2Config): string {
82
+ const normalized = normalizeText(text);
83
+ if (!normalized) return '';
84
+ if (role !== 'assistant' || cfg.capture.roles.assistant !== 'bounded') {
85
+ return normalized;
86
+ }
87
+
88
+ return normalized
89
+ .replace(/```[\s\S]*?```/g, '[Code block omitted from memory capture]')
90
+ .replace(/\n(?:[|].*[|]\n){8,}/g, '\n[Large table omitted from memory capture]\n')
91
+ .replace(/\n(?:[-+].*\n){20,}/g, '\n[Large diff/log omitted from memory capture]\n')
92
+ .trim();
93
+ }
94
+
95
+ function normalizeText(text: string): string {
96
+ return text
97
+ .replace(/\r\n?/g, '\n')
98
+ .replace(/[ \t]+\n/g, '\n')
99
+ .replace(/\n{4,}/g, '\n\n\n')
100
+ .trim();
101
+ }
102
+
103
+ function chunkText(text: string, maxChars: number): string[] {
104
+ if (text.length <= maxChars) return [text];
105
+ const chunks: string[] = [];
106
+ for (let start = 0; start < text.length; start += maxChars) {
107
+ const chunk = text.slice(start, start + maxChars).trim();
108
+ if (chunk) chunks.push(chunk);
109
+ }
110
+ return chunks;
111
+ }
112
+
113
+ function truncate(text: string, maxChars: number): string {
114
+ if (text.length <= maxChars) return text;
115
+ return `${text.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
116
+ }
117
+
118
+ function resolveTimestamp(message: unknown): string | undefined {
119
+ if (typeof message !== 'object' || message === null) return undefined;
120
+ const value = (message as Record<string, unknown>)['timestamp'];
121
+ if (typeof value === 'string') return value;
122
+ if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
123
+ return undefined;
124
+ }
125
+
126
+ function messageKey(message: unknown, role: string, text: string, index: number): string {
127
+ if (typeof message === 'object' && message !== null) {
128
+ const id = (message as Record<string, unknown>)['id'];
129
+ if (typeof id === 'string' && id) return `${role}:${id}`;
130
+ }
131
+ return `${role}:${index}:${text.slice(0, 300)}`;
132
+ }