@hippodid/openclaw-plugin 1.0.8 → 1.0.10

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.
@@ -4,11 +4,20 @@ export function createMemoryFlushHook(
4
4
  fileSync: FileSync,
5
5
  logger: { info(msg: string): void; warn(msg: string): void },
6
6
  ): (api: any) => void {
7
- return (_api: any) => {
8
- // OpenClaw 2026 does not support api.on() for event hooks.
9
- // Pre-compaction flush is available via the hippodid:sync tool,
10
- // which agents can call explicitly before a compaction.
11
- // Background sync is handled by fileSync.start() interval.
12
- logger.info('hippodid: memory flush handler ready (use hippodid:sync tool to trigger)');
7
+ return (api: any) => {
8
+ api.on('before_compaction', async () => {
9
+ try {
10
+ const { synced, changed } = await fileSync.flushNow();
11
+ logger.info(
12
+ `hippodid: flushed ${synced} files before compaction (${changed} changed)`,
13
+ );
14
+ } catch (e) {
15
+ logger.warn(
16
+ `hippodid: compaction flush failed: ${e instanceof Error ? e.message : 'unknown'}`,
17
+ );
18
+ }
19
+ });
20
+
21
+ logger.info('hippodid: memory flush handler registered');
13
22
  };
14
23
  }
@@ -7,10 +7,39 @@ export function createSessionHooks(
7
7
  autoRecall: boolean,
8
8
  logger: { info(msg: string): void; warn(msg: string): void },
9
9
  ): (api: any) => void {
10
- return (_api: any) => {
11
- // OpenClaw 2026 does not support api.on() for session lifecycle events.
12
- // Session start hydration happens automatically via fileSync.start()
13
- // which pulls cloud state on initialization.
14
- logger.info('hippodid: session lifecycle handler ready');
10
+ return (api: any) => {
11
+ api.on('session_start', async () => {
12
+ try {
13
+ await tierManager.initialize();
14
+ if (!tierManager.shouldHydrateOnStart(autoRecall)) {
15
+ logger.info(
16
+ 'hippodid: session start hydration skipped because auto-recall is active',
17
+ );
18
+ return;
19
+ }
20
+
21
+ const hydrated = await fileSync.hydrateFromCloud();
22
+ logger.info(`hippodid: session started, hydrated ${hydrated} files`);
23
+ } catch (e) {
24
+ logger.warn(
25
+ `hippodid: session start error: ${e instanceof Error ? e.message : 'unknown'}`,
26
+ );
27
+ }
28
+ });
29
+
30
+ api.on('session_end', async () => {
31
+ try {
32
+ const { synced, changed } = await fileSync.flushNow();
33
+ logger.info(
34
+ `hippodid: session ended, flushed ${synced} files (${changed} changed)`,
35
+ );
36
+ } catch (e) {
37
+ logger.warn(
38
+ `hippodid: session end error: ${e instanceof Error ? e.message : 'unknown'}`,
39
+ );
40
+ }
41
+ });
42
+
43
+ logger.info('hippodid: session lifecycle hooks registered');
15
44
  };
16
45
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
2
3
  import { resolve } from 'node:path';
3
4
  import type { PluginConfig } from './types.js';
4
5
  import { createClient, type HippoDidClient } from './hippodid-client.js';
@@ -10,39 +11,42 @@ import { createSessionHooks } from './hooks/session-lifecycle.js';
10
11
  import { createAutoRecallHook } from './hooks/auto-recall.js';
11
12
  import { createAutoCaptureHook } from './hooks/auto-capture.js';
12
13
 
13
- const VERSION = '1.0.0';
14
+ const require = createRequire(import.meta.url);
15
+ const VERSION =
16
+ ((require('../package.json') as { version?: string }).version) ?? '0.0.0-dev';
17
+ let hasInitialized = false;
14
18
 
15
19
  export default {
16
20
  id: 'hippodid',
17
21
 
18
22
  register(api: any): void {
19
23
  try {
20
- const config = resolveConfig(api.pluginConfig ?? {});
24
+ if (hasInitialized) {
25
+ return;
26
+ }
27
+
28
+ const config = resolveConfig(api.pluginConfig ?? api.config ?? {});
21
29
  const logger = api.logger ?? {
22
30
  info: (msg: string) => console.log(msg),
23
31
  warn: (msg: string) => console.warn(msg),
24
32
  error: (msg: string) => console.error(msg),
25
33
  };
26
34
 
27
- const apiKey = config?.apiKey?.trim() ?? '';
28
- const characterId = config?.characterId?.trim() ?? '';
29
-
35
+ const apiKey = config.apiKey.trim();
36
+ const characterId = config.characterId.trim();
30
37
  if (!apiKey || !characterId) {
31
- logger.warn('HippoDid: apiKey and characterId required — configure in openclaw.json');
38
+ logger.warn(
39
+ 'HippoDid: apiKey and characterId required — configure in openclaw.json',
40
+ );
32
41
  return;
33
42
  }
34
43
 
44
+ hasInitialized = true;
45
+
35
46
  const client = createClient(apiKey, config.baseUrl);
36
- const tierManager = createTierManager(
37
- client,
38
- characterId,
39
- logger,
40
- );
47
+ const tierManager = createTierManager(client, characterId, logger);
41
48
  const watchPaths = resolveWatchPaths(config);
42
- const effectiveSyncInterval = Math.max(
43
- config.syncIntervalSeconds,
44
- 60,
45
- );
49
+ const effectiveSyncInterval = Math.max(config.syncIntervalSeconds, 60);
46
50
  const fileSync = createFileSync(
47
51
  client,
48
52
  config,
@@ -133,6 +137,7 @@ function resolveConfig(raw: any): PluginConfig {
133
137
  additionalPaths: [],
134
138
  };
135
139
  }
140
+
136
141
  return {
137
142
  apiKey: raw.apiKey ?? '',
138
143
  characterId: raw.characterId ?? '',
@@ -152,49 +157,162 @@ function registerCommands(
152
157
  tierManager: TierManager,
153
158
  logger: { info(msg: string): void; warn(msg: string): void },
154
159
  ): void {
155
- api.registerTool('hippodid:status', {
156
- description: 'Show HippoDid tier, sync status, and watched paths',
157
- handler: async () => {
158
- const tier = tierManager.getCurrentTier();
159
- const statusResult = await client.getSyncStatus(config.characterId);
160
-
161
- logger.info(`--- HippoDid Status ---`);
162
- logger.info(`Character: ${config.characterId}`);
163
- logger.info(`Tier: ${tier.tier}`);
164
- logger.info(
165
- `Auto-Recall: ${tier.features.autoRecallAvailable ? 'available' : 'unavailable'} (config: ${config.autoRecall ? 'ON' : 'OFF'})`,
166
- );
167
- logger.info(
168
- `Auto-Capture: ${tier.features.autoCaptureAvailable ? 'available' : 'unavailable'} (config: ${config.autoCapture ? 'ON' : 'OFF'})`,
160
+ const getStatusText = async (): Promise<string> => {
161
+ const tier = tierManager.getCurrentTier();
162
+ const statusResult = await client.getSyncStatus(config.characterId);
163
+ const lines = [
164
+ 'HippoDid status:',
165
+ `- Character: ${config.characterId}`,
166
+ `- Tier: ${tier.tier}`,
167
+ `- Auto-Recall: ${tier.features.autoRecallAvailable ? 'available' : 'unavailable'} (config: ${config.autoRecall ? 'ON' : 'OFF'})`,
168
+ `- Auto-Capture: ${tier.features.autoCaptureAvailable ? 'available' : 'unavailable'} (config: ${config.autoCapture ? 'ON' : 'OFF'})`,
169
+ ];
170
+
171
+ if (statusResult.ok) {
172
+ lines.push(`- Synced sources: ${statusResult.value.entries.length}`);
173
+ for (const entry of statusResult.value.entries) {
174
+ lines.push(
175
+ ` - ${entry.sourcePath} (${entry.label}) — last sync: ${entry.lastSyncedAt}`,
176
+ );
177
+ }
178
+ } else {
179
+ lines.push(
180
+ `- Sync status: unavailable (${statusResult.error.message})`,
169
181
  );
182
+ }
170
183
 
171
- if (statusResult.ok) {
172
- logger.info(`Synced sources: ${statusResult.value.entries.length}`);
173
- for (const entry of statusResult.value.entries) {
174
- logger.info(
175
- ` ${entry.sourcePath} (${entry.label}) last sync: ${entry.lastSyncedAt}`,
176
- );
184
+ return lines.join('\n');
185
+ };
186
+
187
+ const runImport = async (workspaceOverride?: string): Promise<string> => {
188
+ const { readdir } = await import('node:fs/promises');
189
+ const { join, extname } = await import('node:path');
190
+ const { createHash } = await import('node:crypto');
191
+
192
+ const workspacePath = workspaceOverride
193
+ ? resolve(workspaceOverride)
194
+ : resolve(process.cwd());
195
+
196
+ const memoryDir = join(workspacePath, 'memory');
197
+ const memoryMd = join(workspacePath, 'MEMORY.md');
198
+ const filesToImport: Array<{ path: string; label: string }> = [];
199
+
200
+ try {
201
+ const entries = await readdir(memoryDir);
202
+ for (const entry of entries) {
203
+ if (extname(entry) === '.md') {
204
+ filesToImport.push({
205
+ path: join(memoryDir, entry),
206
+ label: 'workspace-memory',
207
+ });
177
208
  }
178
- } else {
209
+ }
210
+ } catch {
211
+ // memory dir may not exist
212
+ }
213
+
214
+ try {
215
+ await readFile(memoryMd);
216
+ filesToImport.push({ path: memoryMd, label: 'MEMORY.md' });
217
+ } catch {
218
+ // MEMORY.md may not exist
219
+ }
220
+
221
+ if (filesToImport.length === 0) {
222
+ return 'hippodid: no memory files found to import';
223
+ }
224
+
225
+ logger.info(
226
+ `hippodid: importing ${filesToImport.length} files from ${workspacePath}...`,
227
+ );
228
+
229
+ let imported = 0;
230
+ for (const file of filesToImport) {
231
+ try {
232
+ const content = await readFile(file.path);
233
+ const hash = createHash('sha256').update(content).digest('hex');
234
+ const base64 = content.toString('base64');
235
+ const result = await client.syncFile(
236
+ config.characterId,
237
+ file.path,
238
+ file.label,
239
+ base64,
240
+ hash,
241
+ );
242
+ if (result.ok) imported++;
243
+ } catch (e) {
179
244
  logger.warn(
180
- `Could not fetch sync status: ${statusResult.error.message}`,
245
+ `hippodid: import failed for ${file.path}: ${e instanceof Error ? e.message : 'unknown'}`,
181
246
  );
182
247
  }
248
+ }
249
+
250
+ return `hippodid: import complete — ${imported}/${filesToImport.length} files imported`;
251
+ };
252
+
253
+ api.registerCommand({
254
+ name: 'hippodid',
255
+ description: 'Show HippoDid status and run sync/import actions.',
256
+ acceptsArgs: true,
257
+ handler: async (ctx: any) => {
258
+ const args = ctx?.args?.trim() ?? '';
259
+ const [action = 'status', ...rest] = args.split(/\s+/).filter(Boolean);
260
+
261
+ if (action === 'status') {
262
+ return { text: await getStatusText() };
263
+ }
264
+
265
+ if (action === 'sync') {
266
+ logger.info('hippodid: manual sync triggered...');
267
+ const { synced, changed } = await fileSync.flushNow();
268
+ return {
269
+ text: `hippodid: manual sync complete — ${synced} files (${changed} changed)`,
270
+ };
271
+ }
272
+
273
+ if (action === 'import') {
274
+ const workspace = rest.join(' ').trim() || undefined;
275
+ return { text: await runImport(workspace) };
276
+ }
277
+
278
+ return {
279
+ text: [
280
+ 'HippoDid commands:',
281
+ '',
282
+ '/hippodid status',
283
+ '/hippodid sync',
284
+ '/hippodid import [workspace-path]',
285
+ ].join('\n'),
286
+ };
287
+ },
288
+ });
289
+
290
+ api.registerTool({
291
+ name: 'hippodid:status',
292
+ description: 'Show HippoDid tier, sync status, and watched paths',
293
+ execute: async () => {
294
+ const text = await getStatusText();
295
+ for (const line of text.split('\n')) {
296
+ logger.info(line);
297
+ }
298
+ return text;
183
299
  },
184
300
  });
185
301
 
186
- api.registerTool('hippodid:sync', {
302
+ api.registerTool({
303
+ name: 'hippodid:sync',
187
304
  description: 'Trigger immediate sync of all watched files',
188
- handler: async () => {
305
+ execute: async () => {
189
306
  logger.info('hippodid: manual sync triggered...');
190
307
  const { synced, changed } = await fileSync.flushNow();
191
- logger.info(
192
- `hippodid: manual sync complete — ${synced} files (${changed} changed)`,
193
- );
308
+ const text = `hippodid: manual sync complete — ${synced} files (${changed} changed)`;
309
+ logger.info(text);
310
+ return text;
194
311
  },
195
312
  });
196
313
 
197
- api.registerTool('hippodid:import', {
314
+ api.registerTool({
315
+ name: 'hippodid:import',
198
316
  description: 'Import existing workspace memory into HippoDid character',
199
317
  args: [
200
318
  {
@@ -203,73 +321,10 @@ function registerCommands(
203
321
  required: false,
204
322
  },
205
323
  ],
206
- handler: async (args: Record<string, string>) => {
207
- const { readdir } = await import('node:fs/promises');
208
- const { join, extname } = await import('node:path');
209
- const { createHash } = await import('node:crypto');
210
-
211
- const workspacePath = args['workspace']
212
- ? resolve(args['workspace'])
213
- : resolve(process.cwd());
214
-
215
- const memoryDir = join(workspacePath, 'memory');
216
- const memoryMd = join(workspacePath, 'MEMORY.md');
217
- const filesToImport: Array<{ path: string; label: string }> = [];
218
-
219
- try {
220
- const entries = await readdir(memoryDir);
221
- for (const entry of entries) {
222
- if (extname(entry) === '.md') {
223
- filesToImport.push({
224
- path: join(memoryDir, entry),
225
- label: 'workspace-memory',
226
- });
227
- }
228
- }
229
- } catch {
230
- // memory dir may not exist
231
- }
232
-
233
- try {
234
- await readFile(memoryMd);
235
- filesToImport.push({ path: memoryMd, label: 'MEMORY.md' });
236
- } catch {
237
- // MEMORY.md may not exist
238
- }
239
-
240
- if (filesToImport.length === 0) {
241
- logger.info('hippodid: no memory files found to import');
242
- return;
243
- }
244
-
245
- logger.info(
246
- `hippodid: importing ${filesToImport.length} files from ${workspacePath}...`,
247
- );
248
-
249
- let imported = 0;
250
- for (const file of filesToImport) {
251
- try {
252
- const content = await readFile(file.path);
253
- const hash = createHash('sha256').update(content).digest('hex');
254
- const base64 = content.toString('base64');
255
- const result = await client.syncFile(
256
- config.characterId,
257
- file.path,
258
- file.label,
259
- base64,
260
- hash,
261
- );
262
- if (result.ok) imported++;
263
- } catch (e) {
264
- logger.warn(
265
- `hippodid: import failed for ${file.path}: ${e instanceof Error ? e.message : 'unknown'}`,
266
- );
267
- }
268
- }
269
-
270
- logger.info(
271
- `hippodid: import complete — ${imported}/${filesToImport.length} files imported`,
272
- );
324
+ execute: async (args: Record<string, string>) => {
325
+ const text = await runImport(args['workspace']);
326
+ logger.info(text);
327
+ return text;
273
328
  },
274
329
  });
275
330
  }
@@ -52,9 +52,7 @@ export function createTierManager(
52
52
  },
53
53
 
54
54
  shouldMountFileSync(autoCaptureEnabled: boolean): boolean {
55
- const isFree = !isPaidTier(currentTier);
56
- if (isFree) return true;
57
- return !autoCaptureEnabled;
55
+ return !this.shouldMountAutoCapture(autoCaptureEnabled);
58
56
  },
59
57
 
60
58
  shouldMountAutoRecall(autoRecallEnabled: boolean): boolean {
@@ -76,7 +74,7 @@ export function createTierManager(
76
74
  shouldHydrateOnStart(autoRecallEnabled: boolean): boolean {
77
75
  const isFree = !isPaidTier(currentTier);
78
76
  if (isFree) return true;
79
- return !autoRecallEnabled;
77
+ return !this.shouldMountAutoRecall(autoRecallEnabled);
80
78
  },
81
79
 
82
80
  getEffectiveSyncInterval(configInterval: number): number {
package/src/types.ts CHANGED
@@ -59,9 +59,12 @@ export interface TierInfo {
59
59
  export interface TierApiResponse {
60
60
  tier: string;
61
61
  features: {
62
- auto_recall_available: boolean;
63
- auto_capture_available: boolean;
64
- min_sync_interval_seconds: number;
62
+ auto_recall_available?: boolean;
63
+ auto_capture_available?: boolean;
64
+ min_sync_interval_seconds?: number;
65
+ autoRecallAvailable?: boolean;
66
+ autoCaptureAvailable?: boolean;
67
+ minSyncIntervalSeconds?: number;
65
68
  };
66
69
  }
67
70
 
@@ -129,5 +132,5 @@ export interface FileTrackingEntry {
129
132
 
130
133
  // OpenClaw Plugin API — typed as `any` at boundaries.
131
134
  // The real type comes from openclaw/plugin-sdk/core at runtime.
132
- // Methods used: api.registerTool(), api.logger, api.config
135
+ // Methods used: api.registerTool(), api.registerHook(), api.logger, api.pluginConfig
133
136
  export type OpenClawPluginAPI = any;