@keyoku/openclaw 1.0.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.
Files changed (66) hide show
  1. package/dist/capture.d.ts +23 -0
  2. package/dist/capture.d.ts.map +1 -0
  3. package/dist/capture.js +114 -0
  4. package/dist/capture.js.map +1 -0
  5. package/dist/cli.d.ts +8 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +71 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/config.d.ts +28 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +19 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/context.d.ts +22 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +136 -0
  16. package/dist/context.js.map +1 -0
  17. package/dist/heartbeat-setup.d.ts +10 -0
  18. package/dist/heartbeat-setup.d.ts.map +1 -0
  19. package/dist/heartbeat-setup.js +49 -0
  20. package/dist/heartbeat-setup.js.map +1 -0
  21. package/dist/hooks.d.ts +10 -0
  22. package/dist/hooks.d.ts.map +1 -0
  23. package/dist/hooks.js +152 -0
  24. package/dist/hooks.js.map +1 -0
  25. package/dist/incremental-capture.d.ts +24 -0
  26. package/dist/incremental-capture.d.ts.map +1 -0
  27. package/dist/incremental-capture.js +81 -0
  28. package/dist/incremental-capture.js.map +1 -0
  29. package/dist/index.d.ts +24 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +54 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/migration.d.ts +29 -0
  34. package/dist/migration.d.ts.map +1 -0
  35. package/dist/migration.js +203 -0
  36. package/dist/migration.js.map +1 -0
  37. package/dist/service.d.ts +7 -0
  38. package/dist/service.d.ts.map +1 -0
  39. package/dist/service.js +133 -0
  40. package/dist/service.js.map +1 -0
  41. package/dist/tools.d.ts +11 -0
  42. package/dist/tools.d.ts.map +1 -0
  43. package/dist/tools.js +188 -0
  44. package/dist/tools.js.map +1 -0
  45. package/dist/types.d.ts +55 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +8 -0
  48. package/dist/types.js.map +1 -0
  49. package/package.json +31 -0
  50. package/src/capture.ts +116 -0
  51. package/src/cli.ts +95 -0
  52. package/src/config.ts +43 -0
  53. package/src/context.ts +164 -0
  54. package/src/heartbeat-setup.ts +53 -0
  55. package/src/hooks.ts +175 -0
  56. package/src/incremental-capture.ts +88 -0
  57. package/src/index.ts +68 -0
  58. package/src/migration.ts +241 -0
  59. package/src/service.ts +145 -0
  60. package/src/tools.ts +239 -0
  61. package/src/types.ts +40 -0
  62. package/test/capture.test.ts +139 -0
  63. package/test/context.test.ts +273 -0
  64. package/test/hooks.test.ts +137 -0
  65. package/test/tools.test.ts +174 -0
  66. package/tsconfig.json +8 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Migration utility — imports OpenClaw's file-based memories into Keyoku.
3
+ *
4
+ * Reads MEMORY.md and memory/*.md files, chunks them by heading sections,
5
+ * deduplicates against existing Keyoku memories, and stores each chunk.
6
+ *
7
+ * Usage: `openclaw memory import --dir /path/to/workspace`
8
+ */
9
+
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
11
+ import { join, basename } from 'path';
12
+ import type { KeyokuClient } from '@keyoku/memory';
13
+
14
+ export interface ImportResult {
15
+ imported: number;
16
+ skipped: number;
17
+ errors: number;
18
+ }
19
+
20
+ interface MemoryChunk {
21
+ content: string;
22
+ source: string; // original filename
23
+ section?: string; // heading text
24
+ }
25
+
26
+ /**
27
+ * Split markdown content by ## or ### headings.
28
+ * Each heading section becomes one chunk.
29
+ * If no headings, split by --- separators or paragraphs.
30
+ */
31
+ function chunkByHeadings(content: string, maxChunkChars = 1000): MemoryChunk[] {
32
+ const chunks: MemoryChunk[] = [];
33
+
34
+ // Try splitting by headings first
35
+ const headingPattern = /^#{2,3}\s+(.+)$/gm;
36
+ const headings: { index: number; title: string }[] = [];
37
+ let match: RegExpExecArray | null;
38
+
39
+ while ((match = headingPattern.exec(content)) !== null) {
40
+ headings.push({ index: match.index, title: match[1].trim() });
41
+ }
42
+
43
+ if (headings.length > 0) {
44
+ for (let i = 0; i < headings.length; i++) {
45
+ const start = headings[i].index;
46
+ const end = i + 1 < headings.length ? headings[i + 1].index : content.length;
47
+ const sectionText = content.slice(start, end).trim();
48
+
49
+ if (sectionText.length < 10) continue;
50
+
51
+ // If section is too long, split by paragraphs
52
+ if (sectionText.length > maxChunkChars) {
53
+ const paragraphs = splitByParagraphs(sectionText, maxChunkChars);
54
+ for (const p of paragraphs) {
55
+ chunks.push({ content: p, source: '', section: headings[i].title });
56
+ }
57
+ } else {
58
+ chunks.push({ content: sectionText, source: '', section: headings[i].title });
59
+ }
60
+ }
61
+
62
+ // Content before the first heading
63
+ const preamble = content.slice(0, headings[0].index).trim();
64
+ if (preamble.length >= 10) {
65
+ const paragraphs = splitByParagraphs(preamble, maxChunkChars);
66
+ for (const p of paragraphs) {
67
+ chunks.push({ content: p, source: '' });
68
+ }
69
+ }
70
+ } else {
71
+ // No headings — try --- separators
72
+ const sections = content.split(/^---+$/m);
73
+ if (sections.length > 1) {
74
+ for (const section of sections) {
75
+ const trimmed = section.trim();
76
+ if (trimmed.length < 10) continue;
77
+ const paragraphs = splitByParagraphs(trimmed, maxChunkChars);
78
+ for (const p of paragraphs) {
79
+ chunks.push({ content: p, source: '' });
80
+ }
81
+ }
82
+ } else {
83
+ // No structure — split by paragraphs
84
+ const paragraphs = splitByParagraphs(content, maxChunkChars);
85
+ for (const p of paragraphs) {
86
+ chunks.push({ content: p, source: '' });
87
+ }
88
+ }
89
+ }
90
+
91
+ return chunks;
92
+ }
93
+
94
+ /**
95
+ * Split text by double-newline (paragraphs), merging small paragraphs
96
+ * and splitting oversized ones.
97
+ */
98
+ function splitByParagraphs(text: string, maxChars = 1000): string[] {
99
+ const rawParagraphs = text.split(/\n\n+/);
100
+ const results: string[] = [];
101
+ let buffer = '';
102
+
103
+ for (const para of rawParagraphs) {
104
+ const trimmed = para.trim();
105
+ if (!trimmed) continue;
106
+
107
+ if (buffer.length + trimmed.length + 2 <= maxChars) {
108
+ buffer = buffer ? `${buffer}\n\n${trimmed}` : trimmed;
109
+ } else {
110
+ if (buffer) results.push(buffer);
111
+ if (trimmed.length > maxChars) {
112
+ // Hard split at maxChars boundary
113
+ for (let i = 0; i < trimmed.length; i += maxChars) {
114
+ results.push(trimmed.slice(i, i + maxChars));
115
+ }
116
+ buffer = '';
117
+ } else {
118
+ buffer = trimmed;
119
+ }
120
+ }
121
+ }
122
+
123
+ if (buffer && buffer.length >= 10) results.push(buffer);
124
+ return results;
125
+ }
126
+
127
+ /**
128
+ * Small delay helper for rate limiting.
129
+ */
130
+ function delay(ms: number): Promise<void> {
131
+ return new Promise((resolve) => setTimeout(resolve, ms));
132
+ }
133
+
134
+ /**
135
+ * Import OpenClaw memory files into Keyoku.
136
+ */
137
+ export async function importMemoryFiles(params: {
138
+ client: KeyokuClient;
139
+ entityId: string;
140
+ workspaceDir: string;
141
+ agentId?: string;
142
+ dryRun?: boolean;
143
+ logger?: { info: (msg: string) => void; warn: (msg: string) => void };
144
+ }): Promise<ImportResult> {
145
+ const { client, entityId, workspaceDir, agentId, dryRun = false, logger = console } = params;
146
+ const result: ImportResult = { imported: 0, skipped: 0, errors: 0 };
147
+
148
+ // Discover memory files
149
+ const files: { path: string; name: string }[] = [];
150
+
151
+ // Check for MEMORY.md
152
+ const memoryMdPath = join(workspaceDir, 'MEMORY.md');
153
+ if (existsSync(memoryMdPath)) {
154
+ files.push({ path: memoryMdPath, name: 'MEMORY.md' });
155
+ }
156
+
157
+ // Check for memory/ directory
158
+ const memoryDir = join(workspaceDir, 'memory');
159
+ if (existsSync(memoryDir) && statSync(memoryDir).isDirectory()) {
160
+ const entries = readdirSync(memoryDir)
161
+ .filter((f) => f.endsWith('.md'))
162
+ .sort(); // chronological for dated files
163
+
164
+ for (const entry of entries) {
165
+ files.push({ path: join(memoryDir, entry), name: `memory/${entry}` });
166
+ }
167
+ }
168
+
169
+ if (files.length === 0) {
170
+ logger.info('No memory files found in workspace.');
171
+ return result;
172
+ }
173
+
174
+ logger.info(`Found ${files.length} memory file(s) to import.`);
175
+
176
+ // Process each file
177
+ for (const file of files) {
178
+ let content: string;
179
+ try {
180
+ content = readFileSync(file.path, 'utf-8');
181
+ } catch (err) {
182
+ logger.warn(`Failed to read ${file.name}: ${String(err)}`);
183
+ result.errors++;
184
+ continue;
185
+ }
186
+
187
+ if (content.trim().length < 10) {
188
+ logger.info(`Skipping ${file.name} (too short)`);
189
+ result.skipped++;
190
+ continue;
191
+ }
192
+
193
+ const chunks = chunkByHeadings(content);
194
+
195
+ for (const chunk of chunks) {
196
+ chunk.source = file.name;
197
+
198
+ // Build the content to store — include source context
199
+ const taggedContent = chunk.section
200
+ ? `[Imported from ${file.name} — ${chunk.section}]\n${chunk.content}`
201
+ : `[Imported from ${file.name}]\n${chunk.content}`;
202
+
203
+ if (dryRun) {
204
+ logger.info(`[dry-run] Would import: ${taggedContent.slice(0, 80)}...`);
205
+ result.imported++;
206
+ continue;
207
+ }
208
+
209
+ // Dedup check: search for similar content
210
+ try {
211
+ const queryText = chunk.content.slice(0, 100);
212
+ const existing = await client.search(entityId, queryText, { limit: 1, min_score: 0.95 });
213
+
214
+ if (existing.length > 0) {
215
+ result.skipped++;
216
+ continue;
217
+ }
218
+ } catch {
219
+ // Search failed — proceed with import anyway
220
+ }
221
+
222
+ // Store the memory
223
+ try {
224
+ await client.remember(entityId, taggedContent, {
225
+ agent_id: agentId,
226
+ source: 'migration',
227
+ });
228
+ result.imported++;
229
+ logger.info(`Imported: ${chunk.content.slice(0, 60)}...`);
230
+ } catch (err) {
231
+ logger.warn(`Failed to store chunk from ${file.name}: ${String(err)}`);
232
+ result.errors++;
233
+ }
234
+
235
+ // Rate limit
236
+ await delay(50);
237
+ }
238
+ }
239
+
240
+ return result;
241
+ }
package/src/service.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Keyoku binary lifecycle management.
3
+ * Starts/stops the Keyoku Go binary as a child process.
4
+ */
5
+
6
+ import { spawn, type ChildProcess } from 'node:child_process';
7
+ import { existsSync, mkdirSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { randomBytes } from 'node:crypto';
10
+ import type { PluginApi } from './types.js';
11
+
12
+ let keyokuProcess: ChildProcess | null = null;
13
+
14
+ /**
15
+ * Check if Keyoku is already running by attempting a health check.
16
+ */
17
+ async function isKeyokuRunning(url: string): Promise<boolean> {
18
+ try {
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), 2000);
21
+ const res = await fetch(`${url}/health`, { signal: controller.signal });
22
+ clearTimeout(timer);
23
+ return res.ok;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Wait for Keyoku to become healthy, polling every interval up to a timeout.
31
+ */
32
+ async function waitForHealthy(url: string, timeoutMs = 5000, intervalMs = 500): Promise<boolean> {
33
+ const deadline = Date.now() + timeoutMs;
34
+ while (Date.now() < deadline) {
35
+ if (await isKeyokuRunning(url)) return true;
36
+ await new Promise((r) => setTimeout(r, intervalMs));
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Find keyoku binary on disk or PATH.
43
+ */
44
+ function findKeyokuBinary(): string | null {
45
+ const home = process.env.HOME ?? '';
46
+ const candidates = [
47
+ resolve(home, '.keyoku', 'bin', 'keyoku'),
48
+ resolve(home, '.local', 'bin', 'keyoku'),
49
+ ];
50
+
51
+ for (const candidate of candidates) {
52
+ if (existsSync(candidate)) return candidate;
53
+ }
54
+
55
+ // Fall back to PATH resolution
56
+ return 'keyoku';
57
+ }
58
+
59
+ /**
60
+ * Ensure the Keyoku data directory exists and return the DB path.
61
+ */
62
+ function ensureDataDir(): string {
63
+ const dir = resolve(process.env.HOME ?? '', '.keyoku', 'data');
64
+ mkdirSync(dir, { recursive: true });
65
+ return resolve(dir, 'keyoku.db');
66
+ }
67
+
68
+ export function registerService(api: PluginApi, keyokuUrl: string): void {
69
+ api.registerService({
70
+ id: 'keyoku-engine',
71
+
72
+ async start() {
73
+ // Skip if already running
74
+ if (await isKeyokuRunning(keyokuUrl)) {
75
+ api.logger.info('keyoku: Keyoku already running');
76
+ return;
77
+ }
78
+
79
+ const binary = findKeyokuBinary();
80
+ if (!binary) {
81
+ api.logger.warn('keyoku: Keyoku binary not found — memory features require Keyoku to be running');
82
+ return;
83
+ }
84
+
85
+ // Prepare environment
86
+ const env = { ...process.env };
87
+ if (!env.KEYOKU_SESSION_TOKEN) {
88
+ env.KEYOKU_SESSION_TOKEN = randomBytes(16).toString('hex');
89
+ api.logger.info('keyoku: Generated session token');
90
+ }
91
+ if (!env.KEYOKU_DB_PATH) {
92
+ env.KEYOKU_DB_PATH = ensureDataDir();
93
+ api.logger.info(`keyoku: Using database at ${env.KEYOKU_DB_PATH}`);
94
+ }
95
+
96
+ try {
97
+ keyokuProcess = spawn(binary, [], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ detached: false,
100
+ env,
101
+ });
102
+
103
+ // Pipe stdout/stderr to logger
104
+ keyokuProcess.stdout?.on('data', (data: Buffer) => {
105
+ const line = data.toString().trim();
106
+ if (line) api.logger.info(`keyoku: ${line}`);
107
+ });
108
+
109
+ keyokuProcess.stderr?.on('data', (data: Buffer) => {
110
+ const line = data.toString().trim();
111
+ if (line) api.logger.warn(`keyoku: ${line}`);
112
+ });
113
+
114
+ keyokuProcess.on('error', (err) => {
115
+ api.logger.warn(`keyoku: Failed to start Keyoku: ${err.message}`);
116
+ keyokuProcess = null;
117
+ });
118
+
119
+ keyokuProcess.on('exit', (code) => {
120
+ if (code !== 0 && code !== null) {
121
+ api.logger.warn(`keyoku: Keyoku exited with code ${code}`);
122
+ }
123
+ keyokuProcess = null;
124
+ });
125
+
126
+ // Wait for health check with retry
127
+ if (await waitForHealthy(keyokuUrl)) {
128
+ api.logger.info('keyoku: Keyoku started successfully');
129
+ } else {
130
+ api.logger.warn('keyoku: Keyoku started but health check failed — it may still be initializing');
131
+ }
132
+ } catch (err) {
133
+ api.logger.warn(`keyoku: Could not start Keyoku: ${String(err)}`);
134
+ }
135
+ },
136
+
137
+ stop() {
138
+ if (keyokuProcess) {
139
+ keyokuProcess.kill('SIGTERM');
140
+ keyokuProcess = null;
141
+ api.logger.info('keyoku: Keyoku stopped');
142
+ }
143
+ },
144
+ });
145
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * OpenClaw tool registrations for Keyoku memory operations.
3
+ * Registers 7 tools:
4
+ * memory_search, memory_get (OpenClaw standard — replaces built-in file-based memory)
5
+ * memory_store, memory_forget, memory_stats (Keyoku memory management)
6
+ * schedule_create, schedule_list (Keyoku scheduling)
7
+ */
8
+
9
+ import { Type } from '@sinclair/typebox';
10
+ import type { KeyokuClient } from '@keyoku/memory';
11
+ import type { PluginApi } from './types.js';
12
+
13
+ export function registerTools(api: PluginApi, client: KeyokuClient, entityId: string, agentId: string): void {
14
+ // memory_search — OpenClaw-standard search tool (replaces memory-core's built-in)
15
+ api.registerTool(
16
+ {
17
+ name: 'memory_search',
18
+ label: 'Memory Search',
19
+ description:
20
+ 'Search through memories for relevant information. Returns semantically similar memories ranked by relevance.',
21
+ parameters: Type.Object({
22
+ query: Type.String({ description: 'Search query' }),
23
+ maxResults: Type.Optional(Type.Number({ description: 'Max results (default: 5)' })),
24
+ minScore: Type.Optional(Type.Number({ description: 'Minimum relevance score 0-1' })),
25
+ }),
26
+ async execute(_toolCallId, params) {
27
+ const { query, maxResults = 5, minScore = 0.1 } = params as {
28
+ query: string;
29
+ maxResults?: number;
30
+ minScore?: number;
31
+ };
32
+ const results = await client.search(entityId, query, { limit: maxResults, min_score: minScore });
33
+
34
+ if (results.length === 0) {
35
+ return {
36
+ content: [{ type: 'text', text: JSON.stringify({ results: [], provider: 'memory', mode: 'semantic' }) }],
37
+ details: { count: 0 },
38
+ };
39
+ }
40
+
41
+ const mapped = results.map((r) => ({
42
+ path: `mem:${r.memory.id}`,
43
+ startLine: 1,
44
+ endLine: 1,
45
+ score: r.similarity,
46
+ snippet: r.memory.content,
47
+ source: 'memory',
48
+ citation: `mem:${r.memory.id}`,
49
+ }));
50
+
51
+ return {
52
+ content: [
53
+ { type: 'text', text: JSON.stringify({ results: mapped, provider: 'memory', mode: 'semantic' }) },
54
+ ],
55
+ details: { count: mapped.length },
56
+ };
57
+ },
58
+ },
59
+ { name: 'memory_search' },
60
+ );
61
+
62
+ // memory_get — OpenClaw-standard memory read tool (replaces file-based reads)
63
+ api.registerTool(
64
+ {
65
+ name: 'memory_get',
66
+ label: 'Memory Get',
67
+ description:
68
+ 'Read a specific memory by its ID (mem:<id>) or search for a memory by keyword.',
69
+ parameters: Type.Object({
70
+ path: Type.String({ description: 'Memory path (mem:<id>) or keyword to search' }),
71
+ from: Type.Optional(Type.Number({ description: 'Line offset (unused)' })),
72
+ lines: Type.Optional(Type.Number({ description: 'Line count (unused)' })),
73
+ }),
74
+ async execute(_toolCallId, params) {
75
+ const { path: memPath } = params as { path: string; from?: number; lines?: number };
76
+
77
+ if (memPath.startsWith('mem:') || memPath.startsWith('keyoku:')) {
78
+ const id = memPath.startsWith('mem:') ? memPath.slice(4) : memPath.slice(7);
79
+ try {
80
+ const memory = await client.getMemory(id);
81
+ return {
82
+ content: [{ type: 'text', text: JSON.stringify({ text: memory.content, path: memPath }) }],
83
+ };
84
+ } catch {
85
+ return {
86
+ content: [{ type: 'text', text: JSON.stringify({ text: '', path: memPath, error: 'Memory not found' }) }],
87
+ };
88
+ }
89
+ }
90
+
91
+ // Fallback: treat path as a search query
92
+ const results = await client.search(entityId, memPath, { limit: 1 });
93
+ if (results.length > 0) {
94
+ return {
95
+ content: [
96
+ {
97
+ type: 'text',
98
+ text: JSON.stringify({
99
+ text: results[0].memory.content,
100
+ path: `mem:${results[0].memory.id}`,
101
+ }),
102
+ },
103
+ ],
104
+ };
105
+ }
106
+
107
+ return {
108
+ content: [{ type: 'text', text: JSON.stringify({ text: '', path: memPath, error: 'Not found' }) }],
109
+ };
110
+ },
111
+ },
112
+ { name: 'memory_get' },
113
+ );
114
+
115
+ // memory_store — store a new memory
116
+ api.registerTool(
117
+ {
118
+ name: 'memory_store',
119
+ label: 'Memory Store',
120
+ description:
121
+ 'Save important information in long-term memory. Use for preferences, facts, decisions.',
122
+ parameters: Type.Object({
123
+ text: Type.String({ description: 'Information to remember' }),
124
+ }),
125
+ async execute(_toolCallId, params) {
126
+ const { text } = params as { text: string };
127
+ const result = await client.remember(entityId, text, { agent_id: agentId });
128
+
129
+ return {
130
+ content: [{ type: 'text', text: `Stored: "${text.slice(0, 100)}${text.length > 100 ? '...' : ''}"` }],
131
+ details: { memories_created: result.memories_created },
132
+ };
133
+ },
134
+ },
135
+ { name: 'memory_store' },
136
+ );
137
+
138
+ // memory_forget — delete a memory by ID
139
+ api.registerTool(
140
+ {
141
+ name: 'memory_forget',
142
+ label: 'Memory Forget',
143
+ description: 'Delete a specific memory by ID.',
144
+ parameters: Type.Object({
145
+ memory_id: Type.String({ description: 'The memory ID to delete' }),
146
+ }),
147
+ async execute(_toolCallId, params) {
148
+ const { memory_id } = params as { memory_id: string };
149
+ const result = await client.deleteMemory(memory_id);
150
+
151
+ return {
152
+ content: [{ type: 'text', text: `Memory ${memory_id} deleted.` }],
153
+ details: { status: result.status },
154
+ };
155
+ },
156
+ },
157
+ { name: 'memory_forget' },
158
+ );
159
+
160
+ // memory_stats — get memory statistics
161
+ api.registerTool(
162
+ {
163
+ name: 'memory_stats',
164
+ label: 'Memory Stats',
165
+ description: 'Get memory statistics for the current entity.',
166
+ parameters: Type.Object({}),
167
+ async execute() {
168
+ const stats = await client.getStats(entityId);
169
+
170
+ const text = [
171
+ `Total memories: ${stats.total_memories}`,
172
+ `Active memories: ${stats.active_memories}`,
173
+ `By type: ${JSON.stringify(stats.by_type)}`,
174
+ `By state: ${JSON.stringify(stats.by_state)}`,
175
+ ].join('\n');
176
+
177
+ return {
178
+ content: [{ type: 'text', text }],
179
+ details: { ...stats } as Record<string, unknown>,
180
+ };
181
+ },
182
+ },
183
+ { name: 'memory_stats' },
184
+ );
185
+
186
+ // schedule_create — create a scheduled memory
187
+ api.registerTool(
188
+ {
189
+ name: 'schedule_create',
190
+ label: 'Schedule Create',
191
+ description:
192
+ 'Create a scheduled task/reminder. Cron tags: "daily", "weekly", "monthly", or a cron expression.',
193
+ parameters: Type.Object({
194
+ content: Type.String({ description: 'What to schedule' }),
195
+ cron_tag: Type.String({ description: 'Cron tag: "daily", "weekly", "monthly", or cron expression' }),
196
+ }),
197
+ async execute(_toolCallId, params) {
198
+ const { content, cron_tag } = params as { content: string; cron_tag: string };
199
+ const result = await client.createSchedule(entityId, agentId, content, cron_tag);
200
+
201
+ return {
202
+ content: [{ type: 'text', text: `Scheduled: "${content}" (${cron_tag})` }],
203
+ details: { id: result.id },
204
+ };
205
+ },
206
+ },
207
+ { name: 'schedule_create' },
208
+ );
209
+
210
+ // schedule_list — list schedules
211
+ api.registerTool(
212
+ {
213
+ name: 'schedule_list',
214
+ label: 'Schedule List',
215
+ description: 'List active schedules.',
216
+ parameters: Type.Object({}),
217
+ async execute() {
218
+ const schedules = await client.listSchedules(entityId, agentId);
219
+
220
+ if (schedules.length === 0) {
221
+ return {
222
+ content: [{ type: 'text', text: 'No active schedules.' }],
223
+ details: { count: 0 },
224
+ };
225
+ }
226
+
227
+ const text = schedules
228
+ .map((s, i) => `${i + 1}. ${s.content} (id: ${s.id})`)
229
+ .join('\n');
230
+
231
+ return {
232
+ content: [{ type: 'text', text: `${schedules.length} schedules:\n\n${text}` }],
233
+ details: { count: schedules.length },
234
+ };
235
+ },
236
+ },
237
+ { name: 'schedule_list' },
238
+ );
239
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Minimal OpenClaw plugin API types.
3
+ * These mirror the types from openclaw/src/plugins/types.ts
4
+ * so that @keyoku/openclaw can compile without importing openclaw directly.
5
+ * At runtime, the actual OpenClaw API object is passed in.
6
+ */
7
+
8
+ export type PluginLogger = {
9
+ debug?: (message: string) => void;
10
+ info: (message: string) => void;
11
+ warn: (message: string) => void;
12
+ error: (message: string) => void;
13
+ };
14
+
15
+ export type ToolResult = {
16
+ content: Array<{ type: string; text: string }>;
17
+ details?: Record<string, unknown>;
18
+ };
19
+
20
+ export type AgentTool = {
21
+ name: string;
22
+ label?: string;
23
+ description: string;
24
+ parameters: unknown;
25
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
26
+ };
27
+
28
+ export type PluginApi = {
29
+ id: string;
30
+ name: string;
31
+ logger: PluginLogger;
32
+ pluginConfig?: Record<string, unknown>;
33
+ registerTool: (tool: AgentTool, opts?: { name?: string; names?: string[] }) => void;
34
+ registerHook?: (events: string | string[], handler: (...args: unknown[]) => unknown, opts?: Record<string, unknown>) => void;
35
+ registerCli: (registrar: (ctx: { program: unknown; config: unknown; logger: PluginLogger }) => void, opts?: { commands?: string[] }) => void;
36
+ registerService: (service: { id: string; start: (ctx: unknown) => void | Promise<void>; stop?: (ctx: unknown) => void | Promise<void> }) => void;
37
+ resolvePath: (input: string) => string;
38
+ on: (hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }) => void;
39
+ config?: Record<string, unknown>;
40
+ };