@persistio/openclaw-plugin 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -13,6 +13,20 @@ export interface PersistioMemory {
13
13
  categories: string[];
14
14
  confidence: number;
15
15
  }
16
+ export interface RecallBundle {
17
+ user_rules: string[];
18
+ user_preferences: string[];
19
+ task_patterns: string[];
20
+ workflows: string[];
21
+ project: string[];
22
+ constraints: string[];
23
+ decisions: string[];
24
+ system_facts: string[];
25
+ domain_knowledge: string[];
26
+ }
27
+ export interface RecallBundleResponse {
28
+ bundle: RecallBundle;
29
+ }
16
30
  export declare class PersistioClient {
17
31
  private readonly baseURL;
18
32
  private readonly apiKey;
@@ -21,9 +35,11 @@ export declare class PersistioClient {
21
35
  constructor(config: PersistioConfig);
22
36
  private headers;
23
37
  recall(query: string): Promise<PersistioMemory[]>;
38
+ recallBundle(query: string, topK?: number): Promise<RecallBundle>;
24
39
  ingest(sessionId: string, chunks: Array<{
25
40
  role: string;
26
41
  content: string;
42
+ timestamp: string;
27
43
  }>): Promise<void>;
28
44
  addMemory(data: string, subject: string): Promise<void>;
29
45
  deleteMemory(id: string): Promise<void>;
package/dist/client.js CHANGED
@@ -27,6 +27,18 @@ export class PersistioClient {
27
27
  const data = await res.json();
28
28
  return data.memories ?? [];
29
29
  }
30
+ async recallBundle(query, topK) {
31
+ const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
32
+ method: 'POST',
33
+ headers: this.headers(),
34
+ body: JSON.stringify({ query, top_k: topK ?? this.recallTopK }),
35
+ signal: AbortSignal.timeout(this.recallTimeout),
36
+ });
37
+ if (!res.ok)
38
+ throw new Error(`Persistio recallBundle failed: ${res.status}`);
39
+ const data = await res.json();
40
+ return data.bundle;
41
+ }
30
42
  async ingest(sessionId, chunks) {
31
43
  if (chunks.length === 0)
32
44
  return;
package/dist/index.js CHANGED
@@ -14,18 +14,106 @@ function resolveConfig(raw) {
14
14
  function estimateTokens(text) {
15
15
  return Math.ceil(text.length / 4);
16
16
  }
17
- function buildMemoryBlock(memories, budget) {
18
- if (memories.length === 0)
19
- return '';
20
- const lines = ['## Relevant memories from past conversations'];
21
- let used = estimateTokens(lines[0]);
22
- for (const m of memories) {
23
- const line = `- ${m.data} [${m.subject}]`;
24
- const cost = estimateTokens(line);
25
- if (used + cost > budget)
26
- break;
27
- lines.push(line);
28
- used += cost;
17
+ function truncate(text, maxLength) {
18
+ if (text.length <= maxLength)
19
+ return text;
20
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
21
+ }
22
+ function detectTaskType(text) {
23
+ const normalized = text.toLowerCase();
24
+ if (/(error|bug|fail|failing|issue|broken|debug|debugging|trace|stack)/.test(normalized)) {
25
+ return 'troubleshooting';
26
+ }
27
+ if (/(code|coding|typescript|javascript|python|implement|refactor|function|class|api|build|test)/.test(normalized)) {
28
+ return 'coding';
29
+ }
30
+ if (/(plan|planning|roadmap|strategy|steps|milestone|timeline|organize)/.test(normalized)) {
31
+ return 'planning';
32
+ }
33
+ if (/(write|writing|draft|edit|copy|blog|essay|summary|summarize|document)/.test(normalized)) {
34
+ return 'writing';
35
+ }
36
+ return 'general';
37
+ }
38
+ function buildRecallQuery(event) {
39
+ const relevantMessages = Array.isArray(event.messages)
40
+ ? event.messages
41
+ .map((msg) => {
42
+ if (typeof msg !== 'object' || msg === null)
43
+ return null;
44
+ const m = msg;
45
+ const role = m['role'];
46
+ if (role !== 'user' && role !== 'assistant')
47
+ return null;
48
+ const text = extractTextFromMessage(msg);
49
+ if (!text)
50
+ return null;
51
+ return { role, text: text.replace(/\s+/g, ' ').trim() };
52
+ })
53
+ .filter((msg) => msg !== null && msg.text.length > 0)
54
+ : [];
55
+ const lastUserIndex = (() => {
56
+ for (let i = relevantMessages.length - 1; i >= 0; i -= 1) {
57
+ if (relevantMessages[i].role === 'user')
58
+ return i;
59
+ }
60
+ return -1;
61
+ })();
62
+ const lastUserMessage = lastUserIndex >= 0
63
+ ? relevantMessages[lastUserIndex].text
64
+ : event.prompt?.replace(/\s+/g, ' ').trim() || 'recent context';
65
+ const primary = truncate(lastUserMessage, 300);
66
+ const contextStart = Math.max(0, lastUserIndex - 6);
67
+ const contextMessages = lastUserIndex >= 0
68
+ ? relevantMessages.slice(contextStart, lastUserIndex)
69
+ : relevantMessages.slice(-6);
70
+ const contextSummary = truncate(contextMessages
71
+ .map((msg) => `${msg.role === 'user' ? 'U' : 'A'}:${msg.text}`)
72
+ .join(' | '), 200);
73
+ const taskType = detectTaskType(`${primary} ${event.prompt ?? ''}`);
74
+ const parts = [primary];
75
+ if (contextSummary.length > 0)
76
+ parts.push(`Context: ${contextSummary}`);
77
+ parts.push(`[task: ${taskType}]`);
78
+ return truncate(parts.join('\n'), 600);
79
+ }
80
+ function buildMemoryBlock(bundle, budget) {
81
+ const sections = [
82
+ { title: 'Behavioural rules', items: bundle.user_rules },
83
+ { title: 'Preferences', items: bundle.user_preferences },
84
+ { title: 'Task patterns', items: bundle.task_patterns },
85
+ { title: 'Workflows', items: bundle.workflows },
86
+ { title: 'Project', items: bundle.project },
87
+ { title: 'Constraints', items: bundle.constraints },
88
+ { title: 'Decisions', items: bundle.decisions },
89
+ { title: 'System facts', items: bundle.system_facts },
90
+ { title: 'Domain knowledge', items: bundle.domain_knowledge },
91
+ ];
92
+ const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
93
+ const lines = [intro];
94
+ let used = estimateTokens(intro);
95
+ for (const section of sections) {
96
+ const candidates = section.items.filter((item) => item.trim().length > 0);
97
+ if (candidates.length === 0)
98
+ continue;
99
+ const header = `## ${section.title}`;
100
+ const tentativeLines = [...lines, '', header];
101
+ let tentativeUsed = used + estimateTokens(`\n\n${header}`);
102
+ const includedItems = [];
103
+ for (const item of candidates) {
104
+ const line = `- ${item}`;
105
+ const cost = estimateTokens(`\n${line}`);
106
+ if (tentativeUsed + cost > budget) {
107
+ return lines.length > 1 ? lines.join('\n') : '';
108
+ }
109
+ includedItems.push(line);
110
+ tentativeUsed += cost;
111
+ }
112
+ if (includedItems.length > 0) {
113
+ tentativeLines.push(...includedItems);
114
+ lines.splice(0, lines.length, ...tentativeLines);
115
+ used = tentativeUsed;
116
+ }
29
117
  }
30
118
  return lines.length > 1 ? lines.join('\n') : '';
31
119
  }
@@ -73,12 +161,9 @@ export default definePluginEntry({
73
161
  // -------------------------------------------------------------------------
74
162
  api.on('before_prompt_build', async (event) => {
75
163
  try {
76
- // Use the current prompt as the recall query
77
- const query = event.prompt?.slice(0, 500) || 'recent context';
78
- const memories = await client.recall(query);
79
- if (memories.length === 0)
80
- return;
81
- const block = buildMemoryBlock(memories, cfg.tokenBudget);
164
+ const query = buildRecallQuery(event);
165
+ const bundle = await client.recallBundle(query);
166
+ const block = buildMemoryBlock(bundle, cfg.tokenBudget);
82
167
  if (!block)
83
168
  return;
84
169
  return { appendSystemContext: block };
@@ -102,8 +187,13 @@ export default definePluginEntry({
102
187
  if (role !== 'user' && role !== 'assistant')
103
188
  continue;
104
189
  const text = extractTextFromMessage(msg);
190
+ const ts = typeof m['timestamp'] === 'number'
191
+ ? new Date(m['timestamp']).toISOString()
192
+ : typeof m['timestamp'] === 'string'
193
+ ? m['timestamp']
194
+ : new Date().toISOString();
105
195
  if (text && text.length > 0) {
106
- chunks.push({ role: role, content: text });
196
+ chunks.push({ role: role, content: text, timestamp: ts });
107
197
  }
108
198
  }
109
199
  if (chunks.length === 0)
@@ -1,25 +1,43 @@
1
1
  {
2
- "id": "@persistio/openclaw-plugin",
2
+ "id": "openclaw-persistio",
3
3
  "name": "Persistio Memory",
4
4
  "description": "Persistent semantic memory for OpenClaw via Persistio",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "kind": "memory",
7
7
  "activation": {
8
8
  "onStartup": true
9
9
  },
10
10
  "contracts": {
11
- "tools": ["memory_search", "memory_add", "memory_delete", "memory_list"]
11
+ "tools": [
12
+ "memory_search",
13
+ "memory_add",
14
+ "memory_delete",
15
+ "memory_list"
16
+ ]
12
17
  },
13
18
  "configSchema": {
14
19
  "type": "object",
15
20
  "additionalProperties": false,
16
21
  "properties": {
17
- "baseURL": { "type": "string" },
18
- "apiKey": { "type": "string" },
19
- "tokenBudget": { "type": "number" },
20
- "recallTopK": { "type": "number" },
21
- "recallTimeout": { "type": "number" }
22
+ "baseURL": {
23
+ "type": "string"
24
+ },
25
+ "apiKey": {
26
+ "type": "string"
27
+ },
28
+ "tokenBudget": {
29
+ "type": "number"
30
+ },
31
+ "recallTopK": {
32
+ "type": "number"
33
+ },
34
+ "recallTimeout": {
35
+ "type": "number"
36
+ }
22
37
  },
23
- "required": ["baseURL", "apiKey"]
38
+ "required": [
39
+ "baseURL",
40
+ "apiKey"
41
+ ]
24
42
  }
25
43
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@persistio/openclaw-plugin",
3
- "version": "0.1.0",
4
- "description": "OpenClaw plugin for Persistio persistent semantic memory for AI agents",
3
+ "version": "0.1.2",
4
+ "description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "exports": {
package/src/client.ts CHANGED
@@ -15,6 +15,22 @@ export interface PersistioMemory {
15
15
  confidence: number;
16
16
  }
17
17
 
18
+ export interface RecallBundle {
19
+ user_rules: string[];
20
+ user_preferences: string[];
21
+ task_patterns: string[];
22
+ workflows: string[];
23
+ project: string[];
24
+ constraints: string[];
25
+ decisions: string[];
26
+ system_facts: string[];
27
+ domain_knowledge: string[];
28
+ }
29
+
30
+ export interface RecallBundleResponse {
31
+ bundle: RecallBundle;
32
+ }
33
+
18
34
  export class PersistioClient {
19
35
  private readonly baseURL: string;
20
36
  private readonly apiKey: string;
@@ -47,7 +63,19 @@ export class PersistioClient {
47
63
  return data.memories ?? [];
48
64
  }
49
65
 
50
- async ingest(sessionId: string, chunks: Array<{ role: string; content: string }>): Promise<void> {
66
+ async recallBundle(query: string, topK?: number): Promise<RecallBundle> {
67
+ const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
68
+ method: 'POST',
69
+ headers: this.headers(),
70
+ body: JSON.stringify({ query, top_k: topK ?? this.recallTopK }),
71
+ signal: AbortSignal.timeout(this.recallTimeout),
72
+ });
73
+ if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
74
+ const data = await res.json() as RecallBundleResponse;
75
+ return data.bundle;
76
+ }
77
+
78
+ async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
51
79
  if (chunks.length === 0) return;
52
80
  const res = await fetch(`${this.baseURL}/v1/ingest`, {
53
81
  method: 'POST',
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
2
2
  import { Type } from '@sinclair/typebox';
3
- import { PersistioClient, type PersistioConfig } from './client.js';
3
+ import { PersistioClient, type PersistioConfig, type RecallBundle } from './client.js';
4
4
 
5
5
  function resolveConfig(raw: unknown): PersistioConfig {
6
6
  const c = (raw ?? {}) as Record<string, unknown>;
@@ -17,17 +17,116 @@ function estimateTokens(text: string): number {
17
17
  return Math.ceil(text.length / 4);
18
18
  }
19
19
 
20
- function buildMemoryBlock(memories: import('./client.js').PersistioMemory[], budget: number): string {
21
- if (memories.length === 0) return '';
22
- const lines: string[] = ['## Relevant memories from past conversations'];
23
- let used = estimateTokens(lines[0]!);
24
- for (const m of memories) {
25
- const line = `- ${m.data} [${m.subject}]`;
26
- const cost = estimateTokens(line);
27
- if (used + cost > budget) break;
28
- lines.push(line);
29
- used += cost;
20
+ function truncate(text: string, maxLength: number): string {
21
+ if (text.length <= maxLength) return text;
22
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
23
+ }
24
+
25
+ function detectTaskType(text: string): 'troubleshooting' | 'coding' | 'planning' | 'writing' | 'general' {
26
+ const normalized = text.toLowerCase();
27
+ if (/(error|bug|fail|failing|issue|broken|debug|debugging|trace|stack)/.test(normalized)) {
28
+ return 'troubleshooting';
29
+ }
30
+ if (/(code|coding|typescript|javascript|python|implement|refactor|function|class|api|build|test)/.test(normalized)) {
31
+ return 'coding';
32
+ }
33
+ if (/(plan|planning|roadmap|strategy|steps|milestone|timeline|organize)/.test(normalized)) {
34
+ return 'planning';
30
35
  }
36
+ if (/(write|writing|draft|edit|copy|blog|essay|summary|summarize|document)/.test(normalized)) {
37
+ return 'writing';
38
+ }
39
+ return 'general';
40
+ }
41
+
42
+ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): string {
43
+ const relevantMessages = Array.isArray(event.messages)
44
+ ? event.messages
45
+ .map((msg) => {
46
+ if (typeof msg !== 'object' || msg === null) return null;
47
+ const m = msg as Record<string, unknown>;
48
+ const role = m['role'];
49
+ if (role !== 'user' && role !== 'assistant') return null;
50
+ const text = extractTextFromMessage(msg);
51
+ if (!text) return null;
52
+ return { role, text: text.replace(/\s+/g, ' ').trim() };
53
+ })
54
+ .filter((msg): msg is { role: 'user' | 'assistant'; text: string } => msg !== null && msg.text.length > 0)
55
+ : [];
56
+
57
+ const lastUserIndex = (() => {
58
+ for (let i = relevantMessages.length - 1; i >= 0; i -= 1) {
59
+ if (relevantMessages[i]!.role === 'user') return i;
60
+ }
61
+ return -1;
62
+ })();
63
+
64
+ const lastUserMessage = lastUserIndex >= 0
65
+ ? relevantMessages[lastUserIndex]!.text
66
+ : event.prompt?.replace(/\s+/g, ' ').trim() || 'recent context';
67
+ const primary = truncate(lastUserMessage, 300);
68
+
69
+ const contextStart = Math.max(0, lastUserIndex - 6);
70
+ const contextMessages = lastUserIndex >= 0
71
+ ? relevantMessages.slice(contextStart, lastUserIndex)
72
+ : relevantMessages.slice(-6);
73
+ const contextSummary = truncate(
74
+ contextMessages
75
+ .map((msg) => `${msg.role === 'user' ? 'U' : 'A'}:${msg.text}`)
76
+ .join(' | '),
77
+ 200,
78
+ );
79
+
80
+ const taskType = detectTaskType(`${primary} ${event.prompt ?? ''}`);
81
+ const parts = [primary];
82
+ if (contextSummary.length > 0) parts.push(`Context: ${contextSummary}`);
83
+ parts.push(`[task: ${taskType}]`);
84
+ return truncate(parts.join('\n'), 600);
85
+ }
86
+
87
+ function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
88
+ const sections: Array<{ title: string; items: string[] }> = [
89
+ { title: 'Behavioural rules', items: bundle.user_rules },
90
+ { title: 'Preferences', items: bundle.user_preferences },
91
+ { title: 'Task patterns', items: bundle.task_patterns },
92
+ { title: 'Workflows', items: bundle.workflows },
93
+ { title: 'Project', items: bundle.project },
94
+ { title: 'Constraints', items: bundle.constraints },
95
+ { title: 'Decisions', items: bundle.decisions },
96
+ { title: 'System facts', items: bundle.system_facts },
97
+ { title: 'Domain knowledge', items: bundle.domain_knowledge },
98
+ ];
99
+
100
+ const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
101
+ const lines: string[] = [intro];
102
+ let used = estimateTokens(intro);
103
+
104
+ for (const section of sections) {
105
+ const candidates = section.items.filter((item) => item.trim().length > 0);
106
+ if (candidates.length === 0) continue;
107
+
108
+ const header = `## ${section.title}`;
109
+ const tentativeLines = [...lines, '', header];
110
+ let tentativeUsed = used + estimateTokens(`\n\n${header}`);
111
+ const includedItems: string[] = [];
112
+
113
+ for (const item of candidates) {
114
+ const line = `- ${item}`;
115
+ const cost = estimateTokens(`\n${line}`);
116
+ if (tentativeUsed + cost > budget) {
117
+ return lines.length > 1 ? lines.join('\n') : '';
118
+ }
119
+ includedItems.push(line);
120
+ tentativeUsed += cost;
121
+ }
122
+
123
+ if (includedItems.length > 0) {
124
+ tentativeLines.push(...includedItems);
125
+ lines.splice(0, lines.length, ...tentativeLines);
126
+ used = tentativeUsed;
127
+ }
128
+ }
129
+
31
130
  return lines.length > 1 ? lines.join('\n') : '';
32
131
  }
33
132
 
@@ -77,11 +176,9 @@ export default definePluginEntry({
77
176
  // -------------------------------------------------------------------------
78
177
  api.on('before_prompt_build', async (event) => {
79
178
  try {
80
- // Use the current prompt as the recall query
81
- const query = event.prompt?.slice(0, 500) || 'recent context';
82
- const memories = await client.recall(query);
83
- if (memories.length === 0) return;
84
- const block = buildMemoryBlock(memories, cfg.tokenBudget);
179
+ const query = buildRecallQuery(event);
180
+ const bundle = await client.recallBundle(query);
181
+ const block = buildMemoryBlock(bundle, cfg.tokenBudget);
85
182
  if (!block) return;
86
183
  return { appendSystemContext: block };
87
184
  } catch (err) {
@@ -97,15 +194,20 @@ export default definePluginEntry({
97
194
  api.on('agent_end', async (event) => {
98
195
  try {
99
196
  const sessionId = event.runId ?? 'unknown-session';
100
- const chunks: Array<{ role: string; content: string }> = [];
197
+ const chunks: Array<{ role: string; content: string; timestamp: string }> = [];
101
198
 
102
199
  for (const msg of event.messages) {
103
200
  const m = msg as Record<string, unknown>;
104
201
  const role = m['role'];
105
202
  if (role !== 'user' && role !== 'assistant') continue;
106
203
  const text = extractTextFromMessage(msg);
204
+ const ts = typeof m['timestamp'] === 'number'
205
+ ? new Date(m['timestamp']).toISOString()
206
+ : typeof m['timestamp'] === 'string'
207
+ ? m['timestamp']
208
+ : new Date().toISOString();
107
209
  if (text && text.length > 0) {
108
- chunks.push({ role: role as string, content: text });
210
+ chunks.push({ role: role as string, content: text, timestamp: ts });
109
211
  }
110
212
  }
111
213