@hippodid/openclaw-plugin 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.
- package/LICENSE +190 -0
- package/README.md +141 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +38 -0
- package/src/file-sync.ts +250 -0
- package/src/hippodid-client.ts +283 -0
- package/src/hooks/auto-capture.ts +58 -0
- package/src/hooks/auto-recall.ts +67 -0
- package/src/hooks/memory-flush.ts +22 -0
- package/src/hooks/session-lifecycle.ts +42 -0
- package/src/index.ts +220 -0
- package/src/tier-manager.ts +90 -0
- package/src/types.ts +160 -0
- package/src/workspace-detector.ts +104 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiError,
|
|
3
|
+
Result,
|
|
4
|
+
TierInfo,
|
|
5
|
+
TierApiResponse,
|
|
6
|
+
SyncResponse,
|
|
7
|
+
SyncApiResponse,
|
|
8
|
+
SyncLatestResponse,
|
|
9
|
+
SyncLatestApiResponse,
|
|
10
|
+
SyncStatusEntry,
|
|
11
|
+
SyncStatusResponse,
|
|
12
|
+
SearchResult,
|
|
13
|
+
SearchResultApiResponse,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import { ok, err } from './types.js';
|
|
16
|
+
|
|
17
|
+
const MAX_RETRIES = 3;
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
19
|
+
const TIER_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
interface CachedTier {
|
|
22
|
+
info: TierInfo;
|
|
23
|
+
fetchedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HippoDidClient {
|
|
27
|
+
getTier(characterId: string): Promise<Result<TierInfo>>;
|
|
28
|
+
syncFile(
|
|
29
|
+
characterId: string,
|
|
30
|
+
sourcePath: string,
|
|
31
|
+
label: string,
|
|
32
|
+
fileContent: string,
|
|
33
|
+
checksum: string,
|
|
34
|
+
): Promise<Result<SyncResponse>>;
|
|
35
|
+
getLatestSync(
|
|
36
|
+
characterId: string,
|
|
37
|
+
sourcePath: string,
|
|
38
|
+
): Promise<Result<SyncLatestResponse | null>>;
|
|
39
|
+
getSyncStatus(characterId: string): Promise<Result<SyncStatusResponse>>;
|
|
40
|
+
searchMemories(
|
|
41
|
+
characterId: string,
|
|
42
|
+
query: string,
|
|
43
|
+
topK?: number,
|
|
44
|
+
): Promise<Result<SearchResult[]>>;
|
|
45
|
+
addMemory(
|
|
46
|
+
characterId: string,
|
|
47
|
+
content: string,
|
|
48
|
+
source?: string,
|
|
49
|
+
): Promise<Result<void>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createClient(apiKey: string, baseUrl: string): HippoDidClient {
|
|
53
|
+
let tierCache: CachedTier | null = null;
|
|
54
|
+
|
|
55
|
+
function headers(): Record<string, string> {
|
|
56
|
+
return {
|
|
57
|
+
'X-Api-Key': apiKey,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRetryable(status: number): boolean {
|
|
63
|
+
return status === 429 || status >= 500;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toApiError(status: number, message: string): ApiError {
|
|
67
|
+
return { status, message, retryable: isRetryable(status) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fetchWithTimeout(
|
|
71
|
+
url: string,
|
|
72
|
+
init: RequestInit,
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
76
|
+
try {
|
|
77
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function request<T>(
|
|
84
|
+
method: string,
|
|
85
|
+
path: string,
|
|
86
|
+
body?: unknown,
|
|
87
|
+
): Promise<Result<T>> {
|
|
88
|
+
const url = `${baseUrl}${path}`;
|
|
89
|
+
let lastError: ApiError | null = null;
|
|
90
|
+
|
|
91
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
92
|
+
if (attempt > 0) {
|
|
93
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
|
|
94
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const resp = await fetchWithTimeout(url, {
|
|
99
|
+
method,
|
|
100
|
+
headers: headers(),
|
|
101
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!resp.ok) {
|
|
105
|
+
const errorText = await resp.text().catch(() => 'Unknown error');
|
|
106
|
+
lastError = toApiError(resp.status, errorText);
|
|
107
|
+
|
|
108
|
+
if (!isRetryable(resp.status)) {
|
|
109
|
+
return err(lastError);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (resp.status === 204) {
|
|
115
|
+
return ok(undefined as T);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = (await resp.json()) as T;
|
|
119
|
+
return ok(data);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
const message =
|
|
122
|
+
e instanceof Error ? e.message : 'Unknown network error';
|
|
123
|
+
lastError = toApiError(0, message);
|
|
124
|
+
|
|
125
|
+
if (attempt < MAX_RETRIES) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return err(lastError ?? toApiError(0, 'Request failed after retries'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mapTierResponse(raw: TierApiResponse): TierInfo {
|
|
135
|
+
return {
|
|
136
|
+
tier: raw.tier,
|
|
137
|
+
features: {
|
|
138
|
+
autoRecallAvailable: raw.features.auto_recall_available,
|
|
139
|
+
autoCaptureAvailable: raw.features.auto_capture_available,
|
|
140
|
+
minSyncIntervalSeconds: raw.features.min_sync_interval_seconds,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mapSyncResponse(raw: SyncApiResponse): SyncResponse {
|
|
146
|
+
return {
|
|
147
|
+
status: raw.status,
|
|
148
|
+
snapshotId: raw.snapshot_id,
|
|
149
|
+
changed: raw.changed,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function mapSyncLatestResponse(
|
|
154
|
+
raw: SyncLatestApiResponse,
|
|
155
|
+
): SyncLatestResponse {
|
|
156
|
+
return {
|
|
157
|
+
sourcePath: raw.source_path,
|
|
158
|
+
fileContent: raw.file_content,
|
|
159
|
+
snapshotId: raw.snapshot_id,
|
|
160
|
+
syncedAt: raw.synced_at,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async getTier(characterId: string): Promise<Result<TierInfo>> {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (tierCache && now - tierCache.fetchedAt < TIER_CACHE_TTL_MS) {
|
|
168
|
+
return ok(tierCache.info);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = await request<TierApiResponse>(
|
|
172
|
+
'GET',
|
|
173
|
+
`/v1/tier?characterId=${encodeURIComponent(characterId)}`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!result.ok) return result;
|
|
177
|
+
|
|
178
|
+
const info = mapTierResponse(result.value);
|
|
179
|
+
tierCache = { info, fetchedAt: now };
|
|
180
|
+
return ok(info);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async syncFile(
|
|
184
|
+
characterId: string,
|
|
185
|
+
sourcePath: string,
|
|
186
|
+
label: string,
|
|
187
|
+
fileContent: string,
|
|
188
|
+
checksum: string,
|
|
189
|
+
): Promise<Result<SyncResponse>> {
|
|
190
|
+
const result = await request<SyncApiResponse>(
|
|
191
|
+
'POST',
|
|
192
|
+
`/v1/characters/${encodeURIComponent(characterId)}/sync`,
|
|
193
|
+
{
|
|
194
|
+
source_path: sourcePath,
|
|
195
|
+
label,
|
|
196
|
+
file_content: fileContent,
|
|
197
|
+
checksum,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (!result.ok) return result;
|
|
202
|
+
return ok(mapSyncResponse(result.value));
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async getLatestSync(
|
|
206
|
+
characterId: string,
|
|
207
|
+
sourcePath: string,
|
|
208
|
+
): Promise<Result<SyncLatestResponse | null>> {
|
|
209
|
+
const result = await request<SyncLatestApiResponse>(
|
|
210
|
+
'GET',
|
|
211
|
+
`/v1/characters/${encodeURIComponent(characterId)}/sync/latest?source_path=${encodeURIComponent(sourcePath)}`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (!result.ok) {
|
|
215
|
+
if (result.error.status === 404) {
|
|
216
|
+
return ok(null);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return ok(mapSyncLatestResponse(result.value));
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async getSyncStatus(
|
|
225
|
+
characterId: string,
|
|
226
|
+
): Promise<Result<SyncStatusResponse>> {
|
|
227
|
+
const result = await request<{ entries: Array<{ source_path: string; label: string; last_synced_at: string; snapshot_id: string }> }>(
|
|
228
|
+
'GET',
|
|
229
|
+
`/v1/characters/${encodeURIComponent(characterId)}/sync/status`,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!result.ok) return result;
|
|
233
|
+
|
|
234
|
+
return ok({
|
|
235
|
+
entries: result.value.entries.map(
|
|
236
|
+
(e): SyncStatusEntry => ({
|
|
237
|
+
sourcePath: e.source_path,
|
|
238
|
+
label: e.label,
|
|
239
|
+
lastSyncedAt: e.last_synced_at,
|
|
240
|
+
snapshotId: e.snapshot_id,
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async searchMemories(
|
|
247
|
+
characterId: string,
|
|
248
|
+
query: string,
|
|
249
|
+
topK?: number,
|
|
250
|
+
): Promise<Result<SearchResult[]>> {
|
|
251
|
+
const result = await request<SearchResultApiResponse[]>(
|
|
252
|
+
'POST',
|
|
253
|
+
`/v1/characters/${encodeURIComponent(characterId)}/search`,
|
|
254
|
+
{ query, top_k: topK ?? 5 },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (!result.ok) return result;
|
|
258
|
+
|
|
259
|
+
return ok(
|
|
260
|
+
result.value.map(
|
|
261
|
+
(r): SearchResult => ({
|
|
262
|
+
content: r.content,
|
|
263
|
+
category: r.category,
|
|
264
|
+
score: r.score,
|
|
265
|
+
createdAt: r.created_at,
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async addMemory(
|
|
272
|
+
characterId: string,
|
|
273
|
+
content: string,
|
|
274
|
+
source?: string,
|
|
275
|
+
): Promise<Result<void>> {
|
|
276
|
+
return request<void>(
|
|
277
|
+
'POST',
|
|
278
|
+
`/v1/characters/${encodeURIComponent(characterId)}/memories`,
|
|
279
|
+
{ content, source: source ?? 'openclaw-plugin' },
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HippoDidClient } from '../hippodid-client.js';
|
|
2
|
+
import type { PluginConfig, OpenClawPluginAPI } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export function createAutoCaptureHook(
|
|
5
|
+
client: HippoDidClient,
|
|
6
|
+
config: PluginConfig,
|
|
7
|
+
logger: { info(msg: string): void; warn(msg: string): void },
|
|
8
|
+
): (api: OpenClawPluginAPI) => void {
|
|
9
|
+
return (api: OpenClawPluginAPI) => {
|
|
10
|
+
api.hooks.on('agent_end', (...args: unknown[]) => {
|
|
11
|
+
try {
|
|
12
|
+
const exchange = extractExchange(args);
|
|
13
|
+
if (!exchange) return;
|
|
14
|
+
|
|
15
|
+
client
|
|
16
|
+
.addMemory(config.characterId, exchange, 'openclaw-auto-capture')
|
|
17
|
+
.then((result) => {
|
|
18
|
+
if (result.ok) {
|
|
19
|
+
logger.info('hippodid: captured exchange for memory extraction');
|
|
20
|
+
} else {
|
|
21
|
+
logger.warn(
|
|
22
|
+
`hippodid: capture failed: ${result.error.message}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.catch((e) => {
|
|
27
|
+
logger.warn(
|
|
28
|
+
`hippodid: capture error: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
logger.warn(
|
|
33
|
+
`hippodid: capture hook error: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractExchange(args: unknown[]): string | null {
|
|
41
|
+
if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) {
|
|
42
|
+
const event = args[0] as Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
const userMsg =
|
|
45
|
+
typeof event['userMessage'] === 'string' ? event['userMessage'] : '';
|
|
46
|
+
const agentResp =
|
|
47
|
+
typeof event['agentResponse'] === 'string' ? event['agentResponse'] : '';
|
|
48
|
+
|
|
49
|
+
if (userMsg || agentResp) {
|
|
50
|
+
return `User: ${userMsg}\n\nAgent: ${agentResp}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof event['content'] === 'string') return event['content'];
|
|
54
|
+
if (typeof event['text'] === 'string') return event['text'];
|
|
55
|
+
}
|
|
56
|
+
if (typeof args[0] === 'string') return args[0];
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HippoDidClient } from '../hippodid-client.js';
|
|
2
|
+
import type { PluginConfig, OpenClawPluginAPI } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export function createAutoRecallHook(
|
|
5
|
+
client: HippoDidClient,
|
|
6
|
+
config: PluginConfig,
|
|
7
|
+
logger: { info(msg: string): void; warn(msg: string): void },
|
|
8
|
+
): (api: OpenClawPluginAPI) => void {
|
|
9
|
+
return (api: OpenClawPluginAPI) => {
|
|
10
|
+
api.hooks.on('before_agent_start', async (...args: unknown[]) => {
|
|
11
|
+
try {
|
|
12
|
+
const userMessage = extractUserMessage(args);
|
|
13
|
+
if (!userMessage) return;
|
|
14
|
+
|
|
15
|
+
const result = await client.searchMemories(
|
|
16
|
+
config.characterId,
|
|
17
|
+
userMessage,
|
|
18
|
+
5,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
logger.warn(
|
|
23
|
+
`hippodid: recall search failed: ${result.error.message}`,
|
|
24
|
+
);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const memories = result.value;
|
|
29
|
+
if (memories.length === 0) {
|
|
30
|
+
logger.info('hippodid: no relevant memories found');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contextBlock = formatMemoriesBlock(memories);
|
|
35
|
+
api.context.prepend(contextBlock);
|
|
36
|
+
logger.info(`hippodid: recalled ${memories.length} memories for context`);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
logger.warn(
|
|
39
|
+
`hippodid: recall hook error: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractUserMessage(args: unknown[]): string | null {
|
|
47
|
+
if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) {
|
|
48
|
+
const event = args[0] as Record<string, unknown>;
|
|
49
|
+
if (typeof event['message'] === 'string') return event['message'];
|
|
50
|
+
if (typeof event['content'] === 'string') return event['content'];
|
|
51
|
+
if (typeof event['text'] === 'string') return event['text'];
|
|
52
|
+
}
|
|
53
|
+
if (typeof args[0] === 'string') return args[0];
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface MemoryEntry {
|
|
58
|
+
content: string;
|
|
59
|
+
category: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatMemoriesBlock(memories: MemoryEntry[]): string {
|
|
63
|
+
const lines = memories.map(
|
|
64
|
+
(m) => `- [Category: ${m.category}] ${m.content}`,
|
|
65
|
+
);
|
|
66
|
+
return `<hippodid-memories>\n${lines.join('\n')}\n</hippodid-memories>`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FileSync } from '../file-sync.js';
|
|
2
|
+
import type { OpenClawPluginAPI } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export function createMemoryFlushHook(
|
|
5
|
+
fileSync: FileSync,
|
|
6
|
+
logger: { info(msg: string): void; warn(msg: string): void },
|
|
7
|
+
): (api: OpenClawPluginAPI) => void {
|
|
8
|
+
return (api: OpenClawPluginAPI) => {
|
|
9
|
+
api.hooks.on('memoryFlush', async () => {
|
|
10
|
+
try {
|
|
11
|
+
const { synced, changed } = await fileSync.flushNow();
|
|
12
|
+
logger.info(
|
|
13
|
+
`hippodid: pre-compaction flush — synced ${synced} files (${changed} changed)`,
|
|
14
|
+
);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
logger.warn(
|
|
17
|
+
`hippodid: pre-compaction flush failed: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FileSync } from '../file-sync.js';
|
|
2
|
+
import type { TierManager } from '../tier-manager.js';
|
|
3
|
+
import type { OpenClawPluginAPI } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export function createSessionHooks(
|
|
6
|
+
fileSync: FileSync,
|
|
7
|
+
tierManager: TierManager,
|
|
8
|
+
autoRecallEnabled: boolean,
|
|
9
|
+
logger: { info(msg: string): void; warn(msg: string): void },
|
|
10
|
+
): (api: OpenClawPluginAPI) => void {
|
|
11
|
+
return (api: OpenClawPluginAPI) => {
|
|
12
|
+
api.hooks.on('session:start', async () => {
|
|
13
|
+
try {
|
|
14
|
+
await tierManager.initialize();
|
|
15
|
+
|
|
16
|
+
if (tierManager.shouldHydrateOnStart(autoRecallEnabled)) {
|
|
17
|
+
const count = await fileSync.hydrateFromCloud();
|
|
18
|
+
logger.info(`hippodid: session started, hydrated ${count} files from cloud`);
|
|
19
|
+
} else {
|
|
20
|
+
logger.info('hippodid: session started, hydration skipped (autoRecall active)');
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
logger.warn(
|
|
24
|
+
`hippodid: session start error: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
api.hooks.on('session:end', async () => {
|
|
30
|
+
try {
|
|
31
|
+
const { synced, changed } = await fileSync.flushNow();
|
|
32
|
+
logger.info(
|
|
33
|
+
`hippodid: session ended, final sync — ${synced} files (${changed} changed)`,
|
|
34
|
+
);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
logger.warn(
|
|
37
|
+
`hippodid: session end flush failed: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { OpenClawPluginAPI, PluginConfig } from './types.js';
|
|
4
|
+
import { createClient, type HippoDidClient } from './hippodid-client.js';
|
|
5
|
+
import { createFileSync, type FileSync } from './file-sync.js';
|
|
6
|
+
import { resolveWatchPaths } from './workspace-detector.js';
|
|
7
|
+
import { createTierManager, type TierManager } from './tier-manager.js';
|
|
8
|
+
import { createMemoryFlushHook } from './hooks/memory-flush.js';
|
|
9
|
+
import { createSessionHooks } from './hooks/session-lifecycle.js';
|
|
10
|
+
import { createAutoRecallHook } from './hooks/auto-recall.js';
|
|
11
|
+
import { createAutoCaptureHook } from './hooks/auto-capture.js';
|
|
12
|
+
|
|
13
|
+
export const id = 'hippodid';
|
|
14
|
+
|
|
15
|
+
const VERSION = '1.0.0';
|
|
16
|
+
|
|
17
|
+
export default function register(api: OpenClawPluginAPI): void {
|
|
18
|
+
try {
|
|
19
|
+
const config = resolveConfig(api.config);
|
|
20
|
+
const logger = api.logger ?? {
|
|
21
|
+
info: (msg: string) => console.log(msg),
|
|
22
|
+
warn: (msg: string) => console.warn(msg),
|
|
23
|
+
error: (msg: string) => console.error(msg),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const client = createClient(config.apiKey, config.baseUrl);
|
|
27
|
+
const tierManager = createTierManager(client, config.characterId, logger);
|
|
28
|
+
const watchPaths = resolveWatchPaths(config);
|
|
29
|
+
const effectiveSyncInterval = Math.max(config.syncIntervalSeconds, 60);
|
|
30
|
+
const fileSync = createFileSync(client, config, watchPaths, logger, effectiveSyncInterval);
|
|
31
|
+
|
|
32
|
+
const registerMemoryFlush = createMemoryFlushHook(fileSync, logger);
|
|
33
|
+
registerMemoryFlush(api);
|
|
34
|
+
|
|
35
|
+
const registerSessionHooks = createSessionHooks(
|
|
36
|
+
fileSync,
|
|
37
|
+
tierManager,
|
|
38
|
+
config.autoRecall,
|
|
39
|
+
logger,
|
|
40
|
+
);
|
|
41
|
+
registerSessionHooks(api);
|
|
42
|
+
|
|
43
|
+
tierManager.initialize().then((tier) => {
|
|
44
|
+
if (tierManager.shouldMountAutoRecall(config.autoRecall)) {
|
|
45
|
+
const registerAutoRecall = createAutoRecallHook(client, config, logger);
|
|
46
|
+
registerAutoRecall(api);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (tierManager.shouldMountAutoCapture(config.autoCapture)) {
|
|
50
|
+
const registerAutoCapture = createAutoCaptureHook(client, config, logger);
|
|
51
|
+
registerAutoCapture(api);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (tierManager.shouldMountFileSync(config.autoCapture)) {
|
|
55
|
+
fileSync.start();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const autoRecallStatus = tierManager.shouldMountAutoRecall(config.autoRecall)
|
|
59
|
+
? 'ON'
|
|
60
|
+
: 'OFF';
|
|
61
|
+
const autoCaptureStatus = tierManager.shouldMountAutoCapture(config.autoCapture)
|
|
62
|
+
? 'ON'
|
|
63
|
+
: 'OFF';
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
`hippodid: v${VERSION} | character: ${config.characterId} | tier: ${tier.tier} | watching ${watchPaths.length} paths | autoRecall: ${autoRecallStatus} | autoCapture: ${autoCaptureStatus}`,
|
|
67
|
+
);
|
|
68
|
+
}).catch((e) => {
|
|
69
|
+
logger.warn(
|
|
70
|
+
`hippodid: tier initialization failed, running in free mode: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
71
|
+
);
|
|
72
|
+
fileSync.start();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
registerCommands(api, config, client, fileSync, tierManager, logger);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const msg = e instanceof Error ? e.message : 'unknown';
|
|
78
|
+
(api.logger ?? console).error(`hippodid: plugin initialization failed: ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveConfig(raw: PluginConfig): PluginConfig {
|
|
83
|
+
return {
|
|
84
|
+
apiKey: raw.apiKey,
|
|
85
|
+
characterId: raw.characterId,
|
|
86
|
+
baseUrl: raw.baseUrl ?? 'https://api.hippodid.com',
|
|
87
|
+
syncIntervalSeconds: raw.syncIntervalSeconds ?? 300,
|
|
88
|
+
autoRecall: raw.autoRecall ?? false,
|
|
89
|
+
autoCapture: raw.autoCapture ?? false,
|
|
90
|
+
additionalPaths: raw.additionalPaths ?? [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function registerCommands(
|
|
95
|
+
api: OpenClawPluginAPI,
|
|
96
|
+
config: PluginConfig,
|
|
97
|
+
client: HippoDidClient,
|
|
98
|
+
fileSync: FileSync,
|
|
99
|
+
tierManager: TierManager,
|
|
100
|
+
logger: { info(msg: string): void; warn(msg: string): void },
|
|
101
|
+
): void {
|
|
102
|
+
api.commands.register('hippodid:status', {
|
|
103
|
+
description: 'Show HippoDid tier, sync status, and watched paths',
|
|
104
|
+
handler: async () => {
|
|
105
|
+
const tier = tierManager.getCurrentTier();
|
|
106
|
+
const statusResult = await client.getSyncStatus(config.characterId);
|
|
107
|
+
|
|
108
|
+
logger.info(`--- HippoDid Status ---`);
|
|
109
|
+
logger.info(`Character: ${config.characterId}`);
|
|
110
|
+
logger.info(`Tier: ${tier.tier}`);
|
|
111
|
+
logger.info(
|
|
112
|
+
`Auto-Recall: ${tier.features.autoRecallAvailable ? 'available' : 'unavailable'} (config: ${config.autoRecall ? 'ON' : 'OFF'})`,
|
|
113
|
+
);
|
|
114
|
+
logger.info(
|
|
115
|
+
`Auto-Capture: ${tier.features.autoCaptureAvailable ? 'available' : 'unavailable'} (config: ${config.autoCapture ? 'ON' : 'OFF'})`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (statusResult.ok) {
|
|
119
|
+
logger.info(`Synced sources: ${statusResult.value.entries.length}`);
|
|
120
|
+
for (const entry of statusResult.value.entries) {
|
|
121
|
+
logger.info(
|
|
122
|
+
` ${entry.sourcePath} (${entry.label}) — last sync: ${entry.lastSyncedAt}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
logger.warn(`Could not fetch sync status: ${statusResult.error.message}`);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
api.commands.register('hippodid:sync', {
|
|
132
|
+
description: 'Trigger immediate sync of all watched files',
|
|
133
|
+
handler: async () => {
|
|
134
|
+
logger.info('hippodid: manual sync triggered...');
|
|
135
|
+
const { synced, changed } = await fileSync.flushNow();
|
|
136
|
+
logger.info(
|
|
137
|
+
`hippodid: manual sync complete — ${synced} files (${changed} changed)`,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
api.commands.register('hippodid:import', {
|
|
143
|
+
description: 'Import existing workspace memory into HippoDid character',
|
|
144
|
+
args: [
|
|
145
|
+
{
|
|
146
|
+
name: 'workspace',
|
|
147
|
+
description: 'Path to OpenClaw workspace (default: auto-detect)',
|
|
148
|
+
required: false,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
handler: async (args: Record<string, string>) => {
|
|
152
|
+
const { readdir } = await import('node:fs/promises');
|
|
153
|
+
const { join, extname } = await import('node:path');
|
|
154
|
+
const { createHash } = await import('node:crypto');
|
|
155
|
+
|
|
156
|
+
const workspacePath = args['workspace']
|
|
157
|
+
? resolve(args['workspace'])
|
|
158
|
+
: resolve(process.cwd());
|
|
159
|
+
|
|
160
|
+
const memoryDir = join(workspacePath, 'memory');
|
|
161
|
+
const memoryMd = join(workspacePath, 'MEMORY.md');
|
|
162
|
+
const filesToImport: Array<{ path: string; label: string }> = [];
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const entries = await readdir(memoryDir);
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (extname(entry) === '.md') {
|
|
168
|
+
filesToImport.push({
|
|
169
|
+
path: join(memoryDir, entry),
|
|
170
|
+
label: 'workspace-memory',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// memory dir may not exist
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await readFile(memoryMd);
|
|
180
|
+
filesToImport.push({ path: memoryMd, label: 'MEMORY.md' });
|
|
181
|
+
} catch {
|
|
182
|
+
// MEMORY.md may not exist
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (filesToImport.length === 0) {
|
|
186
|
+
logger.info('hippodid: no memory files found to import');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
logger.info(
|
|
191
|
+
`hippodid: importing ${filesToImport.length} files from ${workspacePath}...`,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
let imported = 0;
|
|
195
|
+
for (const file of filesToImport) {
|
|
196
|
+
try {
|
|
197
|
+
const content = await readFile(file.path);
|
|
198
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
199
|
+
const base64 = content.toString('base64');
|
|
200
|
+
const result = await client.syncFile(
|
|
201
|
+
config.characterId,
|
|
202
|
+
file.path,
|
|
203
|
+
file.label,
|
|
204
|
+
base64,
|
|
205
|
+
hash,
|
|
206
|
+
);
|
|
207
|
+
if (result.ok) imported++;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
logger.warn(
|
|
210
|
+
`hippodid: import failed for ${file.path}: ${e instanceof Error ? e.message : 'unknown'}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
logger.info(
|
|
216
|
+
`hippodid: import complete — ${imported}/${filesToImport.length} files imported`,
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|