@yahaha-studio/kichi-forwarder 0.1.0-beta.9 → 0.1.1-beta.1
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/README.md +6 -6
- package/index.ts +177 -114
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +6 -6
- package/skills/kichi-forwarder/references/install.md +22 -18
- package/src/runtime-manager.ts +223 -0
- package/src/service.ts +77 -30
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -50,15 +50,15 @@ Get the `host` and `avatarId` from Kichi, then use them with `kichi_switch_host`
|
|
|
50
50
|
|
|
51
51
|
## Runtime State
|
|
52
52
|
|
|
53
|
-
The plugin stores runtime state in the OpenClaw user directory:
|
|
53
|
+
The plugin stores runtime state per OpenClaw agent in the OpenClaw user directory:
|
|
54
54
|
|
|
55
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world
|
|
56
|
-
- Linux/macOS: `~/.openclaw/kichi-world
|
|
55
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\`
|
|
56
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/`
|
|
57
57
|
|
|
58
|
-
Important files:
|
|
58
|
+
Important files for each agent:
|
|
59
59
|
|
|
60
|
-
- `state.json` stores
|
|
61
|
-
- `hosts/<encoded-host>/identity.json` stores host-specific `avatarId` and `authKey`
|
|
60
|
+
- `state.json` stores that agent's current host and `llmRuntimeEnabled`
|
|
61
|
+
- `hosts/<encoded-host>/identity.json` stores that agent's host-specific `avatarId` and `authKey`
|
|
62
62
|
|
|
63
63
|
## Notes
|
|
64
64
|
|
package/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
1
|
+
import fs from "node:fs";
|
|
4
2
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AnyAgentTool,
|
|
5
|
+
OpenClawPluginApi,
|
|
6
|
+
OpenClawPluginToolContext,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
6
8
|
import { parse } from "./src/config.js";
|
|
9
|
+
import { KichiRuntimeManager } from "./src/runtime-manager.js";
|
|
7
10
|
import { KichiForwarderService } from "./src/service.js";
|
|
8
11
|
import type {
|
|
9
12
|
ActionDefinition,
|
|
@@ -12,13 +15,11 @@ import type {
|
|
|
12
15
|
Album,
|
|
13
16
|
ClockAction,
|
|
14
17
|
ClockConfig,
|
|
15
|
-
KichiState,
|
|
16
18
|
KichiStaticConfig,
|
|
17
19
|
PomodoroPhase,
|
|
18
20
|
PoseType,
|
|
19
21
|
} from "./src/types.js";
|
|
20
22
|
const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
|
|
21
|
-
const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
22
23
|
const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
|
|
23
24
|
beforePromptBuild: {
|
|
24
25
|
poseType: "sit",
|
|
@@ -46,8 +47,6 @@ const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
|
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
50
|
-
const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
|
|
51
50
|
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
52
51
|
const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
|
|
53
52
|
const MAX_AGENT_END_PREVIEW_WIDTH = 10;
|
|
@@ -55,8 +54,6 @@ const MESSAGE_RECEIVED_ELLIPSIS = "...";
|
|
|
55
54
|
const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"] as const;
|
|
56
55
|
let cachedStaticConfig: KichiStaticConfig | null = null;
|
|
57
56
|
let cachedStaticConfigMtime = 0;
|
|
58
|
-
let service: KichiForwarderService | null = null;
|
|
59
|
-
let pluginApi: OpenClawPluginApi | null = null;
|
|
60
57
|
|
|
61
58
|
type IdlePlanPomodoroPhase = typeof IDLE_PLAN_POMODORO_PHASES[number];
|
|
62
59
|
type IdlePlanAction = {
|
|
@@ -192,25 +189,6 @@ function normalizeStaticConfig(value: unknown): KichiStaticConfig {
|
|
|
192
189
|
};
|
|
193
190
|
}
|
|
194
191
|
|
|
195
|
-
function readState(): KichiState {
|
|
196
|
-
if (!fs.existsSync(STATE_PATH)) {
|
|
197
|
-
return {
|
|
198
|
-
llmRuntimeEnabled: DEFAULT_LLM_RUNTIME_ENABLED,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial<KichiState>;
|
|
202
|
-
if (data.currentHost !== undefined && (typeof data.currentHost !== "string" || !data.currentHost.trim())) {
|
|
203
|
-
throw new Error(`Invalid currentHost in ${STATE_PATH}`);
|
|
204
|
-
}
|
|
205
|
-
if (typeof data.llmRuntimeEnabled !== "boolean") {
|
|
206
|
-
throw new Error(`Invalid llmRuntimeEnabled in ${STATE_PATH}`);
|
|
207
|
-
}
|
|
208
|
-
return {
|
|
209
|
-
...(typeof data.currentHost === "string" ? { currentHost: data.currentHost } : {}),
|
|
210
|
-
llmRuntimeEnabled: data.llmRuntimeEnabled,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
192
|
function loadStaticConfig(): KichiStaticConfig {
|
|
215
193
|
const configPath = fileURLToPath(BUNDLED_STATIC_CONFIG_PATH);
|
|
216
194
|
const stat = fs.statSync(configPath);
|
|
@@ -222,9 +200,9 @@ function loadStaticConfig(): KichiStaticConfig {
|
|
|
222
200
|
return cachedStaticConfig;
|
|
223
201
|
}
|
|
224
202
|
|
|
225
|
-
function sendStatusUpdate(status: ActionResult): void {
|
|
203
|
+
function sendStatusUpdate(service: KichiForwarderService, status: ActionResult): void {
|
|
226
204
|
const actionDefinition = getActionDefinition(status.poseType, status.action);
|
|
227
|
-
service
|
|
205
|
+
service.sendStatus(
|
|
228
206
|
status.poseType,
|
|
229
207
|
actionDefinition.name,
|
|
230
208
|
status.bubble || status.action,
|
|
@@ -233,19 +211,15 @@ function sendStatusUpdate(status: ActionResult): void {
|
|
|
233
211
|
);
|
|
234
212
|
}
|
|
235
213
|
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function syncFixedStatus(status: ActionResult): void {
|
|
241
|
-
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
214
|
+
function syncFixedStatus(service: KichiForwarderService, status: ActionResult): void {
|
|
215
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
242
216
|
return;
|
|
243
217
|
}
|
|
244
218
|
const bubbleText = status.bubble.trim() || status.action;
|
|
245
219
|
const logText = typeof status.log === "string" && status.log.trim()
|
|
246
220
|
? status.log.trim()
|
|
247
221
|
: bubbleText;
|
|
248
|
-
sendStatusUpdate({
|
|
222
|
+
sendStatusUpdate(service, {
|
|
249
223
|
...status,
|
|
250
224
|
bubble: bubbleText,
|
|
251
225
|
log: logText,
|
|
@@ -304,6 +278,56 @@ function stripReplyTag(text: string): string {
|
|
|
304
278
|
return text.replace(/^\[\[\s*reply_to(?::[^\]]+|_current)?\s*\]\]\s*/i, "").trim();
|
|
305
279
|
}
|
|
306
280
|
|
|
281
|
+
function stripKnownLeadingIdentifiers(text: string, candidates: string[]): string {
|
|
282
|
+
let normalized = text.trim();
|
|
283
|
+
if (!normalized) {
|
|
284
|
+
return "";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const separatorsPattern = String.raw`(?:[\s,:;,:;]|$)+`;
|
|
288
|
+
let changed = true;
|
|
289
|
+
while (changed && normalized) {
|
|
290
|
+
changed = false;
|
|
291
|
+
for (const candidate of candidates) {
|
|
292
|
+
const trimmed = candidate.trim();
|
|
293
|
+
if (!trimmed) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
297
|
+
const patterns = [
|
|
298
|
+
new RegExp(`^${escaped}${separatorsPattern}`, "i"),
|
|
299
|
+
new RegExp(`^@${escaped}${separatorsPattern}`, "i"),
|
|
300
|
+
new RegExp(`^<@${escaped}>${separatorsPattern}`, "i"),
|
|
301
|
+
];
|
|
302
|
+
for (const pattern of patterns) {
|
|
303
|
+
if (!pattern.test(normalized)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
normalized = normalized.replace(pattern, "").trimStart();
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return normalized.trim();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function stripDispatchMetadata(
|
|
316
|
+
text: string,
|
|
317
|
+
context?: {
|
|
318
|
+
senderId?: string;
|
|
319
|
+
accountId?: string;
|
|
320
|
+
},
|
|
321
|
+
): string {
|
|
322
|
+
let normalized = stripReplyTag(text);
|
|
323
|
+
normalized = normalized.replace(/^(?:\[[a-z_]+:\s*[^\]]+\]\s*)+/i, "").trim();
|
|
324
|
+
normalized = stripKnownLeadingIdentifiers(normalized, [
|
|
325
|
+
typeof context?.senderId === "string" ? context.senderId : "",
|
|
326
|
+
typeof context?.accountId === "string" ? context.accountId : "",
|
|
327
|
+
]);
|
|
328
|
+
return normalized;
|
|
329
|
+
}
|
|
330
|
+
|
|
307
331
|
function extractTextFromContent(content: unknown): string {
|
|
308
332
|
if (typeof content === "string") {
|
|
309
333
|
return stripReplyTag(content);
|
|
@@ -354,26 +378,62 @@ function getLastAssistantPreview(messages: unknown, maxWidth: number): string {
|
|
|
354
378
|
return "";
|
|
355
379
|
}
|
|
356
380
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
381
|
+
function resolveDispatchMessageText(
|
|
382
|
+
event: { body?: string; content: string },
|
|
383
|
+
context?: {
|
|
384
|
+
senderId?: string;
|
|
385
|
+
accountId?: string;
|
|
386
|
+
},
|
|
387
|
+
): string {
|
|
388
|
+
if (typeof event.content === "string" && event.content.trim()) {
|
|
389
|
+
return stripDispatchMetadata(event.content, context);
|
|
390
|
+
}
|
|
391
|
+
if (typeof event.body === "string" && event.body.trim()) {
|
|
392
|
+
return stripDispatchMetadata(event.body, context);
|
|
393
|
+
}
|
|
394
|
+
return "";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function notifyMessageReceived(
|
|
398
|
+
api: OpenClawPluginApi,
|
|
399
|
+
service: KichiForwarderService,
|
|
400
|
+
content: string,
|
|
401
|
+
): void {
|
|
402
|
+
const connected = service.isConnected();
|
|
403
|
+
const hasIdentity = service.hasValidIdentity();
|
|
404
|
+
api.logger.debug(`[kichi:${service.getAgentId()}] inbound sync fired (connected=${connected}, hasIdentity=${hasIdentity})`);
|
|
361
405
|
if (!hasIdentity || !connected) {
|
|
362
|
-
|
|
406
|
+
api.logger.debug(`[kichi:${service.getAgentId()}] skipped inbound sync because runtime is not ready`);
|
|
363
407
|
return;
|
|
364
408
|
}
|
|
365
409
|
const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
|
|
366
|
-
|
|
410
|
+
api.logger.debug(`[kichi:${service.getAgentId()}] sending message_received notify with preview: ${trimmed || "(empty)"}`);
|
|
367
411
|
service.sendHookNotify("message_received", `"${trimmed}"`);
|
|
368
412
|
}
|
|
369
413
|
|
|
370
|
-
function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
414
|
+
function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntimeManager): void {
|
|
415
|
+
api.on("before_dispatch", (event, ctx) => {
|
|
416
|
+
const service = runtimeManager.getRuntime(ctx);
|
|
417
|
+
if (!service) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const content = resolveDispatchMessageText(event, {
|
|
421
|
+
senderId: ctx.senderId,
|
|
422
|
+
accountId: ctx.accountId,
|
|
423
|
+
});
|
|
424
|
+
if (!content) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
notifyMessageReceived(api, service, content);
|
|
428
|
+
});
|
|
429
|
+
|
|
371
430
|
api.on("before_prompt_build", (_event, ctx) => {
|
|
372
|
-
|
|
431
|
+
const service = runtimeManager.getRuntime(ctx);
|
|
432
|
+
if (!service?.hasValidIdentity() || !service.isConnected()) {
|
|
373
433
|
return;
|
|
374
434
|
}
|
|
375
|
-
if (!isLlmRuntimeEnabled()) {
|
|
376
|
-
syncFixedStatus(FIXED_HOOK_STATUSES.beforePromptBuild);
|
|
435
|
+
if (!service.isLlmRuntimeEnabled()) {
|
|
436
|
+
syncFixedStatus(service, FIXED_HOOK_STATUSES.beforePromptBuild);
|
|
377
437
|
return;
|
|
378
438
|
}
|
|
379
439
|
if (ctx.trigger === "heartbeat") {
|
|
@@ -384,32 +444,36 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
|
384
444
|
};
|
|
385
445
|
});
|
|
386
446
|
|
|
387
|
-
api.on("before_tool_call", (_event,
|
|
388
|
-
|
|
389
|
-
|
|
447
|
+
api.on("before_tool_call", (_event, ctx) => {
|
|
448
|
+
const service = runtimeManager.getRuntime(ctx);
|
|
449
|
+
if (!service) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (!service.isLlmRuntimeEnabled()) {
|
|
453
|
+
syncFixedStatus(service, FIXED_HOOK_STATUSES.beforeToolCall);
|
|
390
454
|
}
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
api.on("message_received", async (event) => {
|
|
394
|
-
await handleMessageReceivedHook(event.content);
|
|
395
455
|
});
|
|
396
456
|
|
|
397
457
|
api.on("agent_end", (event, ctx) => {
|
|
458
|
+
const service = runtimeManager.getRuntime(ctx);
|
|
398
459
|
const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
|
|
399
|
-
|
|
400
|
-
`[kichi] agent_end
|
|
460
|
+
api.logger.debug(
|
|
461
|
+
`[kichi:${service?.getAgentId() ?? "unknown"}] agent_end fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
|
|
401
462
|
);
|
|
402
463
|
if (ctx.trigger === "heartbeat") {
|
|
403
464
|
return;
|
|
404
465
|
}
|
|
405
|
-
if (event.success && preview) {
|
|
406
|
-
|
|
407
|
-
service
|
|
466
|
+
if (service && event.success && preview) {
|
|
467
|
+
api.logger.debug(`[kichi:${service.getAgentId()}] sending before_send_message notify with bubble: ${preview}`);
|
|
468
|
+
service.sendHookNotify("before_send_message", preview);
|
|
408
469
|
}
|
|
409
|
-
if (isLlmRuntimeEnabled()) {
|
|
470
|
+
if (!service || service.isLlmRuntimeEnabled()) {
|
|
410
471
|
return;
|
|
411
472
|
}
|
|
412
|
-
syncFixedStatus(
|
|
473
|
+
syncFixedStatus(
|
|
474
|
+
service,
|
|
475
|
+
event.success ? FIXED_HOOK_STATUSES.agentEndSuccess : FIXED_HOOK_STATUSES.agentEndFailure,
|
|
476
|
+
);
|
|
413
477
|
});
|
|
414
478
|
}
|
|
415
479
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -875,27 +939,41 @@ function buildKichiPrompt(): string {
|
|
|
875
939
|
].join("\n");
|
|
876
940
|
}
|
|
877
941
|
|
|
942
|
+
function createAgentScopedTool(
|
|
943
|
+
runtimeManager: KichiRuntimeManager,
|
|
944
|
+
factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
|
|
945
|
+
) {
|
|
946
|
+
return (ctx: OpenClawPluginToolContext) => {
|
|
947
|
+
const service = runtimeManager.getRuntime(ctx, { createIfMissing: true });
|
|
948
|
+
if (!service) {
|
|
949
|
+
throw new Error("Failed to resolve agent-scoped Kichi runtime");
|
|
950
|
+
}
|
|
951
|
+
return factory(service, ctx);
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
878
955
|
const plugin = {
|
|
879
956
|
id: "kichi-forwarder",
|
|
880
957
|
name: "Kichi Forwarder",
|
|
881
958
|
configSchema: { parse },
|
|
882
959
|
|
|
883
960
|
register(api: OpenClawPluginApi) {
|
|
884
|
-
|
|
885
|
-
registerPluginHooks(api);
|
|
961
|
+
const runtimeManager = new KichiRuntimeManager(api.logger);
|
|
962
|
+
registerPluginHooks(api, runtimeManager);
|
|
886
963
|
const musicTitleEnum = getMusicTitleEnum();
|
|
887
964
|
|
|
888
965
|
api.registerService({
|
|
889
966
|
id: "kichi-forwarder",
|
|
890
967
|
start: (ctx) => {
|
|
891
968
|
parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
|
|
892
|
-
|
|
893
|
-
|
|
969
|
+
runtimeManager.startPersistedRuntimes();
|
|
970
|
+
},
|
|
971
|
+
stop: () => {
|
|
972
|
+
runtimeManager.stopAll();
|
|
894
973
|
},
|
|
895
|
-
stop: () => service?.stop(),
|
|
896
974
|
});
|
|
897
975
|
|
|
898
|
-
api.registerTool({
|
|
976
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
899
977
|
name: "kichi_join",
|
|
900
978
|
description: "Join Kichi world with avatarId, the current bot name, a short bio, and personality tags",
|
|
901
979
|
parameters: {
|
|
@@ -926,7 +1004,7 @@ const plugin = {
|
|
|
926
1004
|
(params as { tags?: unknown } | null)?.tags,
|
|
927
1005
|
);
|
|
928
1006
|
if (!avatarId) {
|
|
929
|
-
avatarId = service
|
|
1007
|
+
avatarId = service.readSavedAvatarId() ?? undefined;
|
|
930
1008
|
}
|
|
931
1009
|
if (!avatarId) {
|
|
932
1010
|
return { success: false, error: "No avatarId" };
|
|
@@ -940,10 +1018,7 @@ const plugin = {
|
|
|
940
1018
|
if (tagsError) {
|
|
941
1019
|
return { success: false, error: tagsError };
|
|
942
1020
|
}
|
|
943
|
-
const result = await service
|
|
944
|
-
if (!result) {
|
|
945
|
-
return { success: false, error: "Kichi service is not initialized" };
|
|
946
|
-
}
|
|
1021
|
+
const result = await service.join(avatarId, botName, bio, tags ?? []);
|
|
947
1022
|
if (result.success) {
|
|
948
1023
|
return { success: true, authKey: result.authKey };
|
|
949
1024
|
}
|
|
@@ -954,9 +1029,9 @@ const plugin = {
|
|
|
954
1029
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
955
1030
|
};
|
|
956
1031
|
},
|
|
957
|
-
});
|
|
1032
|
+
})));
|
|
958
1033
|
|
|
959
|
-
api.registerTool({
|
|
1034
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
960
1035
|
name: "kichi_switch_host",
|
|
961
1036
|
description:
|
|
962
1037
|
"Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
|
|
@@ -971,9 +1046,6 @@ const plugin = {
|
|
|
971
1046
|
required: ["host"],
|
|
972
1047
|
},
|
|
973
1048
|
execute: async (_toolCallId, params) => {
|
|
974
|
-
if (!service) {
|
|
975
|
-
return { success: false, error: "Kichi service is not initialized" };
|
|
976
|
-
}
|
|
977
1049
|
const host = (params as { host?: unknown } | null)?.host;
|
|
978
1050
|
if (!isKichiHost(host)) {
|
|
979
1051
|
return { success: false, error: "host must be a non-empty hostname without protocol or path" };
|
|
@@ -986,18 +1058,14 @@ const plugin = {
|
|
|
986
1058
|
status,
|
|
987
1059
|
};
|
|
988
1060
|
},
|
|
989
|
-
});
|
|
1061
|
+
})));
|
|
990
1062
|
|
|
991
|
-
api.registerTool({
|
|
1063
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
992
1064
|
name: "kichi_rejoin",
|
|
993
1065
|
description:
|
|
994
1066
|
"Request an immediate rejoin attempt with saved avatarId/authKey. Rejoin is also sent automatically after reconnect.",
|
|
995
1067
|
parameters: { type: "object", properties: {} },
|
|
996
1068
|
execute: async () => {
|
|
997
|
-
if (!service) {
|
|
998
|
-
return { success: false, error: "Kichi service is not initialized" };
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
1069
|
const result = service.requestRejoin();
|
|
1002
1070
|
return {
|
|
1003
1071
|
success: result.accepted,
|
|
@@ -1005,17 +1073,14 @@ const plugin = {
|
|
|
1005
1073
|
status: service.getConnectionStatus(),
|
|
1006
1074
|
};
|
|
1007
1075
|
},
|
|
1008
|
-
});
|
|
1076
|
+
})));
|
|
1009
1077
|
|
|
1010
|
-
api.registerTool({
|
|
1078
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1011
1079
|
name: "kichi_leave",
|
|
1012
1080
|
description: "Leave Kichi world",
|
|
1013
1081
|
parameters: { type: "object", properties: {} },
|
|
1014
1082
|
execute: async () => {
|
|
1015
|
-
const result = await service
|
|
1016
|
-
if (!result) {
|
|
1017
|
-
return { success: false, error: "Kichi service is not initialized" };
|
|
1018
|
-
}
|
|
1083
|
+
const result = await service.leave();
|
|
1019
1084
|
if (result.success) {
|
|
1020
1085
|
return { success: true };
|
|
1021
1086
|
}
|
|
@@ -1026,24 +1091,21 @@ const plugin = {
|
|
|
1026
1091
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
1027
1092
|
};
|
|
1028
1093
|
},
|
|
1029
|
-
});
|
|
1094
|
+
})));
|
|
1030
1095
|
|
|
1031
|
-
api.registerTool({
|
|
1096
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1032
1097
|
name: "kichi_status",
|
|
1033
1098
|
description: "Read current Kichi connection status and identity readiness",
|
|
1034
1099
|
parameters: { type: "object", properties: {} },
|
|
1035
1100
|
execute: async () => {
|
|
1036
|
-
if (!service) {
|
|
1037
|
-
return { success: false, error: "Kichi service is not initialized" };
|
|
1038
|
-
}
|
|
1039
1101
|
return {
|
|
1040
1102
|
success: true,
|
|
1041
1103
|
status: service.getConnectionStatus(),
|
|
1042
1104
|
};
|
|
1043
1105
|
},
|
|
1044
|
-
});
|
|
1106
|
+
})));
|
|
1045
1107
|
|
|
1046
|
-
api.registerTool({
|
|
1108
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1047
1109
|
name: "kichi_action",
|
|
1048
1110
|
description: buildKichiActionDescription(),
|
|
1049
1111
|
parameters: {
|
|
@@ -1079,7 +1141,7 @@ const plugin = {
|
|
|
1079
1141
|
error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
|
|
1080
1142
|
};
|
|
1081
1143
|
}
|
|
1082
|
-
if (!service
|
|
1144
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1083
1145
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1084
1146
|
}
|
|
1085
1147
|
|
|
@@ -1097,6 +1159,7 @@ const plugin = {
|
|
|
1097
1159
|
const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched.name;
|
|
1098
1160
|
const logText = typeof log === "string" ? log.trim() : "";
|
|
1099
1161
|
sendStatusUpdate(
|
|
1162
|
+
service,
|
|
1100
1163
|
{
|
|
1101
1164
|
poseType: normalizedPoseType,
|
|
1102
1165
|
action: matched.name,
|
|
@@ -1113,8 +1176,8 @@ const plugin = {
|
|
|
1113
1176
|
playback: getActionPlayback(matched),
|
|
1114
1177
|
};
|
|
1115
1178
|
},
|
|
1116
|
-
});
|
|
1117
|
-
api.registerTool({
|
|
1179
|
+
})));
|
|
1180
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1118
1181
|
name: "kichi_idle_plan",
|
|
1119
1182
|
description: buildKichiIdlePlanDescription(),
|
|
1120
1183
|
parameters: {
|
|
@@ -1197,7 +1260,7 @@ const plugin = {
|
|
|
1197
1260
|
if (!idlePlan) {
|
|
1198
1261
|
return { success: false, error: error ?? "Invalid idle plan payload" };
|
|
1199
1262
|
}
|
|
1200
|
-
if (!service
|
|
1263
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1201
1264
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1202
1265
|
}
|
|
1203
1266
|
const sent = service.sendIdlePlan({
|
|
@@ -1218,8 +1281,8 @@ const plugin = {
|
|
|
1218
1281
|
stages: idlePlan.stages,
|
|
1219
1282
|
};
|
|
1220
1283
|
},
|
|
1221
|
-
});
|
|
1222
|
-
api.registerTool({
|
|
1284
|
+
})));
|
|
1285
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1223
1286
|
name: "kichi_clock",
|
|
1224
1287
|
description:
|
|
1225
1288
|
"Send clock commands to Kichi world. Supported actions are set and stop.",
|
|
@@ -1304,7 +1367,7 @@ const plugin = {
|
|
|
1304
1367
|
return { success: false, error: "requestId must be a string when provided" };
|
|
1305
1368
|
}
|
|
1306
1369
|
const normalizedRequestId = typeof requestId === "string" ? requestId : undefined;
|
|
1307
|
-
if (!service
|
|
1370
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1308
1371
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1309
1372
|
}
|
|
1310
1373
|
|
|
@@ -1329,9 +1392,9 @@ const plugin = {
|
|
|
1329
1392
|
...(normalizedClock ? { clock: normalizedClock } : {}),
|
|
1330
1393
|
};
|
|
1331
1394
|
},
|
|
1332
|
-
});
|
|
1395
|
+
})));
|
|
1333
1396
|
|
|
1334
|
-
api.registerTool({
|
|
1397
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1335
1398
|
name: "kichi_query_status",
|
|
1336
1399
|
description:
|
|
1337
1400
|
"Query Kichi avatar status (notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, and `hasCreatedMusicAlbumToday`). Use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
|
|
@@ -1349,7 +1412,7 @@ const plugin = {
|
|
|
1349
1412
|
if (requestId !== undefined && typeof requestId !== "string") {
|
|
1350
1413
|
return { success: false, error: "requestId must be a string when provided" };
|
|
1351
1414
|
}
|
|
1352
|
-
if (!service
|
|
1415
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1353
1416
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1354
1417
|
}
|
|
1355
1418
|
|
|
@@ -1365,9 +1428,9 @@ const plugin = {
|
|
|
1365
1428
|
};
|
|
1366
1429
|
}
|
|
1367
1430
|
},
|
|
1368
|
-
});
|
|
1431
|
+
})));
|
|
1369
1432
|
|
|
1370
|
-
api.registerTool({
|
|
1433
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1371
1434
|
name: "kichi_music_album_create",
|
|
1372
1435
|
description: buildMusicAlbumToolDescription(),
|
|
1373
1436
|
parameters: {
|
|
@@ -1429,7 +1492,7 @@ const plugin = {
|
|
|
1429
1492
|
examples: getMusicTitleExamples(),
|
|
1430
1493
|
};
|
|
1431
1494
|
}
|
|
1432
|
-
if (!service
|
|
1495
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1433
1496
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1434
1497
|
}
|
|
1435
1498
|
|
|
@@ -1453,9 +1516,9 @@ const plugin = {
|
|
|
1453
1516
|
};
|
|
1454
1517
|
}
|
|
1455
1518
|
},
|
|
1456
|
-
});
|
|
1519
|
+
})));
|
|
1457
1520
|
|
|
1458
|
-
api.registerTool({
|
|
1521
|
+
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1459
1522
|
name: "kichi_noteboard_create",
|
|
1460
1523
|
description:
|
|
1461
1524
|
"Create a new note on a specific Kichi note board. Prefer querying first so you can avoid duplicate posts and respect rate limits.",
|
|
@@ -1490,7 +1553,7 @@ const plugin = {
|
|
|
1490
1553
|
error: `data must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`,
|
|
1491
1554
|
};
|
|
1492
1555
|
}
|
|
1493
|
-
if (!service
|
|
1556
|
+
if (!service.hasValidIdentity() || !service.isConnected()) {
|
|
1494
1557
|
return { success: false, error: "Not connected to Kichi world" };
|
|
1495
1558
|
}
|
|
1496
1559
|
|
|
@@ -1504,7 +1567,7 @@ const plugin = {
|
|
|
1504
1567
|
};
|
|
1505
1568
|
}
|
|
1506
1569
|
},
|
|
1507
|
-
});
|
|
1570
|
+
})));
|
|
1508
1571
|
|
|
1509
1572
|
},
|
|
1510
1573
|
};
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "kichi-forwarder",
|
|
3
3
|
"name": "Kichi Forwarder",
|
|
4
4
|
"description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.1-beta.1",
|
|
6
6
|
"author": "OpenClaw",
|
|
7
7
|
"skills": ["./skills/kichi-forwarder"],
|
|
8
8
|
"configSchema": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yahaha-studio/kichi-forwarder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-beta.1",
|
|
4
4
|
"description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -23,10 +23,10 @@ If this skill is loaded from a remote URL before local installation, use these f
|
|
|
23
23
|
|
|
24
24
|
## Runtime State
|
|
25
25
|
|
|
26
|
-
Runtime data lives under the user home directory:
|
|
26
|
+
Runtime data lives under the user home directory and is isolated per OpenClaw agent:
|
|
27
27
|
|
|
28
|
-
- `state.json`: stores `currentHost` and `llmRuntimeEnabled`
|
|
29
|
-
- `hosts/<encoded-host>/identity.json`: stores host-specific `avatarId` and `authKey`
|
|
28
|
+
- `kichi-world/agents/<encoded-agent-id>/state.json`: stores that agent's `currentHost` and `llmRuntimeEnabled`
|
|
29
|
+
- `kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`: stores that agent's host-specific `avatarId` and `authKey`
|
|
30
30
|
|
|
31
31
|
## Remote URL Install Entry
|
|
32
32
|
|
|
@@ -53,7 +53,7 @@ For install/onboarding/connect requests:
|
|
|
53
53
|
|
|
54
54
|
## LLM Runtime
|
|
55
55
|
|
|
56
|
-
`llmRuntimeEnabled` lives in `state.json`.
|
|
56
|
+
`llmRuntimeEnabled` lives in the current agent's `state.json`.
|
|
57
57
|
|
|
58
58
|
- When `true`, sync status uses LLM-driven prompts and may consume extra tokens.
|
|
59
59
|
- When `false`, sync uses fixed English text.
|
|
@@ -172,8 +172,8 @@ kichi_music_album_create(albumTitle: "Deep Focus Mix", musicTitles: ["Calm Time"
|
|
|
172
172
|
|
|
173
173
|
Plugin runtime directory:
|
|
174
174
|
|
|
175
|
-
- Linux/macOS: `~/.openclaw/kichi-world
|
|
176
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world
|
|
175
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/`
|
|
176
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\`
|
|
177
177
|
|
|
178
178
|
Runtime files:
|
|
179
179
|
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
## Runtime Files
|
|
8
8
|
|
|
9
|
-
Persist runtime state to `state.json`:
|
|
9
|
+
Persist runtime state to the current agent's `state.json`:
|
|
10
10
|
|
|
11
|
-
- Linux/macOS: `~/.openclaw/kichi-world/state.json`
|
|
12
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\state.json`
|
|
11
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/state.json`
|
|
12
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\state.json`
|
|
13
13
|
|
|
14
14
|
```json
|
|
15
15
|
{
|
|
@@ -18,10 +18,10 @@ Persist runtime state to `state.json`:
|
|
|
18
18
|
}
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Save `avatarId` to the host-specific `identity.json` before using `kichi_join`:
|
|
21
|
+
Save `avatarId` to the current agent's host-specific `identity.json` before using `kichi_join`:
|
|
22
22
|
|
|
23
|
-
- Linux/macOS: `~/.openclaw/kichi-world/hosts/<encoded-host>/identity.json`
|
|
24
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\hosts\<encoded-host>\identity.json`
|
|
23
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
|
|
24
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
|
|
25
25
|
|
|
26
26
|
```json
|
|
27
27
|
{
|
|
@@ -67,23 +67,27 @@ When the user asks with one of the commands above, execute in this fixed order:
|
|
|
67
67
|
|
|
68
68
|
1. Read `install.md` and `heartbeat.md` from the published skill URLs before any tool call.
|
|
69
69
|
2. Parse `avatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
|
|
70
|
-
3. Resolve the host and write `state.json`.
|
|
71
|
-
4.
|
|
72
|
-
5. If
|
|
73
|
-
6.
|
|
74
|
-
7. If the plugin
|
|
75
|
-
8.
|
|
76
|
-
9. If `
|
|
77
|
-
10.
|
|
78
|
-
11. If the
|
|
79
|
-
12.
|
|
80
|
-
13.
|
|
70
|
+
3. Resolve the host and write the current agent's `state.json`.
|
|
71
|
+
4. Check whether `@yahaha-studio/kichi-forwarder` is already installed.
|
|
72
|
+
5. If the plugin already exists, check whether the installed version is the latest published version.
|
|
73
|
+
6. If the plugin is missing, run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
|
|
74
|
+
7. If the plugin is already installed but the version is not the latest, run `openclaw plugins update @yahaha-studio/kichi-forwarder`.
|
|
75
|
+
8. If step 6 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
|
|
76
|
+
9. If step 7 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then overwrite the existing install with `openclaw plugins install <tgz-path> --force`.
|
|
77
|
+
10. Ensure the plugin is installed, enabled, and at the latest version.
|
|
78
|
+
11. If the plugin was newly installed or upgraded in this flow, re-check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
|
|
79
|
+
12. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md).
|
|
80
|
+
13. If `HEARTBEAT.md` was not updated successfully, report setup as incomplete and stop immediately. Do not continue to `kichi_status` or `kichi_join`.
|
|
81
|
+
14. Call `kichi_status`.
|
|
82
|
+
15. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
|
|
83
|
+
16. If `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
84
|
+
17. Call `kichi_status` again and confirm connection and auth state.
|
|
81
85
|
|
|
82
86
|
## Required Post-install Integration
|
|
83
87
|
|
|
84
88
|
Use this completion checklist:
|
|
85
89
|
|
|
86
|
-
- [ ] plugin installed and
|
|
90
|
+
- [ ] plugin installed, enabled, and at latest version
|
|
87
91
|
- [ ] `HEARTBEAT.md` updated with the Kichi heartbeat workflow snippet from [heartbeat.md](heartbeat.md)
|
|
88
92
|
- [ ] `kichi_status` verified the final connected/auth state
|
|
89
93
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Logger } from "openclaw/plugin-sdk";
|
|
5
|
+
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
6
|
+
import { KichiForwarderService } from "./service.js";
|
|
7
|
+
|
|
8
|
+
const OPENCLAW_HOME_DIR = path.join(os.homedir(), ".openclaw");
|
|
9
|
+
const KICHI_WORLD_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-world");
|
|
10
|
+
const CANONICAL_AGENT_ROOT_DIR = path.join(KICHI_WORLD_ROOT_DIR, "agents");
|
|
11
|
+
const PREVIOUS_AGENT_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-forwarder", "agents");
|
|
12
|
+
const LEGACY_GLOBAL_STATE_PATH = path.join(KICHI_WORLD_ROOT_DIR, "state.json");
|
|
13
|
+
const LEGACY_GLOBAL_HOSTS_DIR = path.join(KICHI_WORLD_ROOT_DIR, "hosts");
|
|
14
|
+
const LEGACY_MIGRATION_AGENT_ID = "main";
|
|
15
|
+
|
|
16
|
+
type AgentLocator = {
|
|
17
|
+
agentId?: string;
|
|
18
|
+
sessionKey?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type GetRuntimeOptions = {
|
|
22
|
+
createIfMissing?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class KichiRuntimeManager {
|
|
26
|
+
private services = new Map<string, KichiForwarderService>();
|
|
27
|
+
|
|
28
|
+
constructor(private logger: Logger) {}
|
|
29
|
+
|
|
30
|
+
getRuntime(locator: AgentLocator, options: GetRuntimeOptions = {}): KichiForwarderService | null {
|
|
31
|
+
const agentId = this.resolveAgentId(locator);
|
|
32
|
+
if (!agentId) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const existing = this.services.get(agentId);
|
|
37
|
+
if (existing) {
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!options.createIfMissing && !this.hasPersistedRuntime(agentId)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.createRuntime(agentId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startPersistedRuntimes(): void {
|
|
49
|
+
this.migrateRuntimeStorage();
|
|
50
|
+
|
|
51
|
+
const rootDir = CANONICAL_AGENT_ROOT_DIR;
|
|
52
|
+
if (!fs.existsSync(rootDir)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
57
|
+
if (!entry.isDirectory()) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const runtimeDir = path.join(rootDir, entry.name);
|
|
61
|
+
const statePath = path.join(runtimeDir, "state.json");
|
|
62
|
+
if (!fs.existsSync(statePath)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const agentId = decodeURIComponent(entry.name);
|
|
67
|
+
if (this.services.has(agentId)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.createRuntime(agentId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stopAll(): void {
|
|
76
|
+
for (const service of this.services.values()) {
|
|
77
|
+
service.stop();
|
|
78
|
+
}
|
|
79
|
+
this.services.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private resolveAgentId(locator: AgentLocator): string | null {
|
|
83
|
+
if (typeof locator.agentId === "string" && locator.agentId.trim()) {
|
|
84
|
+
return locator.agentId.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof locator.sessionKey !== "string" || !locator.sessionKey.trim()) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const agentId = resolveAgentIdFromSessionKey(locator.sessionKey);
|
|
93
|
+
return typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private hasPersistedRuntime(agentId: string): boolean {
|
|
100
|
+
return fs.existsSync(path.join(this.getRuntimeDir(agentId), "state.json"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private migrateRuntimeStorage(): void {
|
|
104
|
+
// Temporary startup migration for this release. Remove after users have
|
|
105
|
+
// moved off the legacy/global layout and the temporary kichi-forwarder path.
|
|
106
|
+
this.runMigrationStep("previous-agent-root", () => {
|
|
107
|
+
this.migratePreviousAgentRoot();
|
|
108
|
+
});
|
|
109
|
+
this.runMigrationStep("legacy-global-root", () => {
|
|
110
|
+
this.migrateLegacyGlobalRoot();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private migratePreviousAgentRoot(): void {
|
|
115
|
+
if (!fs.existsSync(PREVIOUS_AGENT_ROOT_DIR)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(CANONICAL_AGENT_ROOT_DIR)) {
|
|
120
|
+
fs.mkdirSync(path.dirname(CANONICAL_AGENT_ROOT_DIR), { recursive: true, mode: 0o700 });
|
|
121
|
+
fs.renameSync(PREVIOUS_AGENT_ROOT_DIR, CANONICAL_AGENT_ROOT_DIR);
|
|
122
|
+
this.logger.info(`[kichi:migration] moved ${PREVIOUS_AGENT_ROOT_DIR} to ${CANONICAL_AGENT_ROOT_DIR}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const entry of fs.readdirSync(PREVIOUS_AGENT_ROOT_DIR, { withFileTypes: true })) {
|
|
127
|
+
const sourcePath = path.join(PREVIOUS_AGENT_ROOT_DIR, entry.name);
|
|
128
|
+
const targetPath = path.join(CANONICAL_AGENT_ROOT_DIR, entry.name);
|
|
129
|
+
this.movePathIntoTarget(sourcePath, targetPath);
|
|
130
|
+
}
|
|
131
|
+
this.removeDirectoryIfEmpty(PREVIOUS_AGENT_ROOT_DIR);
|
|
132
|
+
this.removeDirectoryIfEmpty(path.dirname(PREVIOUS_AGENT_ROOT_DIR));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private migrateLegacyGlobalRoot(): void {
|
|
136
|
+
const hasLegacyState = fs.existsSync(LEGACY_GLOBAL_STATE_PATH);
|
|
137
|
+
const hasLegacyHosts = fs.existsSync(LEGACY_GLOBAL_HOSTS_DIR);
|
|
138
|
+
if (!hasLegacyState && !hasLegacyHosts) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const targetRuntimeDir = this.getRuntimeDir(LEGACY_MIGRATION_AGENT_ID);
|
|
143
|
+
fs.mkdirSync(targetRuntimeDir, { recursive: true, mode: 0o700 });
|
|
144
|
+
|
|
145
|
+
if (hasLegacyState) {
|
|
146
|
+
const targetStatePath = path.join(targetRuntimeDir, "state.json");
|
|
147
|
+
this.movePathIntoTarget(LEGACY_GLOBAL_STATE_PATH, targetStatePath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (hasLegacyHosts) {
|
|
151
|
+
const targetHostsDir = path.join(targetRuntimeDir, "hosts");
|
|
152
|
+
this.movePathIntoTarget(LEGACY_GLOBAL_HOSTS_DIR, targetHostsDir);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private movePathIntoTarget(sourcePath: string, targetPath: string): void {
|
|
157
|
+
if (!fs.existsSync(sourcePath)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!fs.existsSync(targetPath)) {
|
|
162
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
163
|
+
fs.renameSync(sourcePath, targetPath);
|
|
164
|
+
this.logger.info(`[kichi:migration] moved ${sourcePath} to ${targetPath}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sourceStat = fs.lstatSync(sourcePath);
|
|
169
|
+
const targetStat = fs.lstatSync(targetPath);
|
|
170
|
+
|
|
171
|
+
if (sourceStat.isDirectory() && targetStat.isDirectory()) {
|
|
172
|
+
for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
173
|
+
const nextSourcePath = path.join(sourcePath, entry.name);
|
|
174
|
+
const nextTargetPath = path.join(targetPath, entry.name);
|
|
175
|
+
this.movePathIntoTarget(nextSourcePath, nextTargetPath);
|
|
176
|
+
}
|
|
177
|
+
this.removeDirectoryIfEmpty(sourcePath);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fs.rmSync(sourcePath, { recursive: sourceStat.isDirectory(), force: true });
|
|
182
|
+
this.logger.warn(`[kichi:migration] dropped ${sourcePath} because target already exists at ${targetPath}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private removeDirectoryIfEmpty(dirPath: string): void {
|
|
186
|
+
if (!fs.existsSync(dirPath)) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!fs.lstatSync(dirPath).isDirectory()) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (fs.readdirSync(dirPath).length > 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
fs.rmdirSync(dirPath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private runMigrationStep(label: string, fn: () => void): void {
|
|
199
|
+
try {
|
|
200
|
+
fn();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.logger.warn(`[kichi:migration] skipped ${label} due to error: ${String(error)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private createRuntime(agentId: string): KichiForwarderService {
|
|
207
|
+
const runtimeDir = this.getRuntimeDir(agentId);
|
|
208
|
+
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
209
|
+
|
|
210
|
+
const service = new KichiForwarderService(this.logger, {
|
|
211
|
+
agentId,
|
|
212
|
+
runtimeDir,
|
|
213
|
+
});
|
|
214
|
+
service.start();
|
|
215
|
+
this.services.set(agentId, service);
|
|
216
|
+
this.logger.debug(`[kichi:${agentId}] runtime initialized at ${runtimeDir}`);
|
|
217
|
+
return service;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private getRuntimeDir(agentId: string): string {
|
|
221
|
+
return path.join(CANONICAL_AGENT_ROOT_DIR, encodeURIComponent(agentId));
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import * as fs from "fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import * as path from "path";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
@@ -27,9 +26,6 @@ import type {
|
|
|
27
26
|
StatusPayload,
|
|
28
27
|
} from "./types.js";
|
|
29
28
|
|
|
30
|
-
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
31
|
-
const HOSTS_DIR = path.join(KICHI_WORLD_DIR, "hosts");
|
|
32
|
-
const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
|
|
33
29
|
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
34
30
|
const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
35
31
|
|
|
@@ -53,6 +49,11 @@ export type LeaveResult =
|
|
|
53
49
|
}
|
|
54
50
|
| AckFailureResult;
|
|
55
51
|
|
|
52
|
+
type KichiForwarderServiceOptions = {
|
|
53
|
+
agentId: string;
|
|
54
|
+
runtimeDir: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
56
57
|
export class KichiForwarderService {
|
|
57
58
|
private ws: WebSocket | null = null;
|
|
58
59
|
private stopped = false;
|
|
@@ -70,9 +71,12 @@ export class KichiForwarderService {
|
|
|
70
71
|
}
|
|
71
72
|
>();
|
|
72
73
|
|
|
73
|
-
constructor(
|
|
74
|
+
constructor(
|
|
75
|
+
private logger: Logger,
|
|
76
|
+
private options: KichiForwarderServiceOptions,
|
|
77
|
+
) {}
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
start(): void {
|
|
76
80
|
this.host = this.loadCurrentHost();
|
|
77
81
|
this.identity = this.host ? this.loadIdentity() : null;
|
|
78
82
|
this.stopped = false;
|
|
@@ -80,10 +84,10 @@ export class KichiForwarderService {
|
|
|
80
84
|
this.connect();
|
|
81
85
|
return;
|
|
82
86
|
}
|
|
83
|
-
this.
|
|
87
|
+
this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
|
|
90
|
+
stop(): void {
|
|
87
91
|
this.stopped = true;
|
|
88
92
|
this.clearReconnectTimeout();
|
|
89
93
|
this.rejectPendingRequests("Kichi websocket stopped");
|
|
@@ -270,10 +274,26 @@ export class KichiForwarderService {
|
|
|
270
274
|
|
|
271
275
|
hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
|
|
272
276
|
|
|
277
|
+
isLlmRuntimeEnabled(): boolean {
|
|
278
|
+
return this.readStateFile()?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED;
|
|
279
|
+
}
|
|
280
|
+
|
|
273
281
|
getCurrentHost(): string {
|
|
274
282
|
return this.host ?? "";
|
|
275
283
|
}
|
|
276
284
|
|
|
285
|
+
getAgentId(): string {
|
|
286
|
+
return this.options.agentId;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getRuntimeDir(): string {
|
|
290
|
+
return this.options.runtimeDir;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
getStatePath(): string {
|
|
294
|
+
return path.join(this.options.runtimeDir, "state.json");
|
|
295
|
+
}
|
|
296
|
+
|
|
277
297
|
getIdentityPath(): string {
|
|
278
298
|
if (!this.host) {
|
|
279
299
|
return "";
|
|
@@ -336,6 +356,9 @@ export class KichiForwarderService {
|
|
|
336
356
|
getConnectionStatus(): KichiConnectionStatus {
|
|
337
357
|
const host = this.host ?? undefined;
|
|
338
358
|
return {
|
|
359
|
+
agentId: this.options.agentId,
|
|
360
|
+
runtimeDir: this.getRuntimeDir(),
|
|
361
|
+
statePath: this.getStatePath(),
|
|
339
362
|
...(host ? {
|
|
340
363
|
host,
|
|
341
364
|
wsUrl: this.getWsUrl(),
|
|
@@ -372,7 +395,7 @@ export class KichiForwarderService {
|
|
|
372
395
|
resolve({ success: true });
|
|
373
396
|
}
|
|
374
397
|
} catch (e) {
|
|
375
|
-
this.
|
|
398
|
+
this.log("warn", `failed to parse leave response: ${e}`);
|
|
376
399
|
}
|
|
377
400
|
};
|
|
378
401
|
this.ws!.on("message", handler);
|
|
@@ -395,7 +418,7 @@ export class KichiForwarderService {
|
|
|
395
418
|
|
|
396
419
|
ws.on("open", () => {
|
|
397
420
|
if (this.ws !== ws) return;
|
|
398
|
-
this.
|
|
421
|
+
this.log("info", `connected to ${wsUrl} (${this.host})`);
|
|
399
422
|
this.sendRejoinPayload();
|
|
400
423
|
});
|
|
401
424
|
|
|
@@ -420,7 +443,7 @@ export class KichiForwarderService {
|
|
|
420
443
|
}
|
|
421
444
|
|
|
422
445
|
private handleMessage(data: string): void {
|
|
423
|
-
this.
|
|
446
|
+
this.log("debug", `ws recv ${data}`);
|
|
424
447
|
try {
|
|
425
448
|
const msg = JSON.parse(data);
|
|
426
449
|
this.tryResolvePendingRequest(msg);
|
|
@@ -428,7 +451,7 @@ export class KichiForwarderService {
|
|
|
428
451
|
const joinAck = msg as JoinAckPayload;
|
|
429
452
|
if (joinAck.success === false || !joinAck.authKey) {
|
|
430
453
|
const failure = this.buildAckFailure(joinAck, "Join failed");
|
|
431
|
-
this.
|
|
454
|
+
this.log("warn", `join failed: ${failure.error}`);
|
|
432
455
|
this.joinResolve?.(failure);
|
|
433
456
|
this.joinResolve = null;
|
|
434
457
|
return;
|
|
@@ -437,24 +460,24 @@ export class KichiForwarderService {
|
|
|
437
460
|
if (this.identity) {
|
|
438
461
|
this.identity.authKey = joinAck.authKey;
|
|
439
462
|
this.saveIdentity();
|
|
440
|
-
this.
|
|
463
|
+
this.log("info", `joined as ${this.identity.avatarId}`);
|
|
441
464
|
}
|
|
442
465
|
this.joinResolve?.({ success: true, authKey: joinAck.authKey });
|
|
443
466
|
this.joinResolve = null;
|
|
444
467
|
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
445
|
-
this.
|
|
468
|
+
this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
|
|
446
469
|
this.clearAuthKey();
|
|
447
470
|
} else if (msg.type === "leave_ack") {
|
|
448
471
|
const leaveAck = msg as LeaveAckPayload;
|
|
449
472
|
if (leaveAck.success === false) {
|
|
450
473
|
const failure = this.buildAckFailure(leaveAck, "Leave failed");
|
|
451
|
-
this.
|
|
474
|
+
this.log("warn", `leave failed: ${failure.error}`);
|
|
452
475
|
} else {
|
|
453
|
-
this.
|
|
476
|
+
this.log("info", "left Kichi world");
|
|
454
477
|
}
|
|
455
478
|
}
|
|
456
479
|
} catch (e) {
|
|
457
|
-
this.
|
|
480
|
+
this.log("warn", `failed to parse message: ${e}`);
|
|
458
481
|
}
|
|
459
482
|
}
|
|
460
483
|
|
|
@@ -571,7 +594,7 @@ export class KichiForwarderService {
|
|
|
571
594
|
}
|
|
572
595
|
return null;
|
|
573
596
|
} catch (e) {
|
|
574
|
-
this.
|
|
597
|
+
this.log("warn", `failed to load identity: ${e}`);
|
|
575
598
|
return null;
|
|
576
599
|
}
|
|
577
600
|
}
|
|
@@ -584,7 +607,7 @@ export class KichiForwarderService {
|
|
|
584
607
|
if (!fs.existsSync(identityDir)) fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
585
608
|
fs.writeFileSync(identityPath, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
|
|
586
609
|
} catch (e) {
|
|
587
|
-
this.
|
|
610
|
+
this.log("error", `failed to save identity: ${e}`);
|
|
588
611
|
}
|
|
589
612
|
}
|
|
590
613
|
|
|
@@ -592,7 +615,7 @@ export class KichiForwarderService {
|
|
|
592
615
|
if (!this.identity) return;
|
|
593
616
|
this.identity.authKey = undefined;
|
|
594
617
|
this.saveIdentity();
|
|
595
|
-
this.
|
|
618
|
+
this.log("info", "authKey cleared");
|
|
596
619
|
}
|
|
597
620
|
|
|
598
621
|
private sendRejoinPayload(): boolean {
|
|
@@ -603,7 +626,7 @@ export class KichiForwarderService {
|
|
|
603
626
|
this.ws.send(
|
|
604
627
|
JSON.stringify({ type: "rejoin", avatarId: this.identity.avatarId, authKey: this.identity.authKey }),
|
|
605
628
|
);
|
|
606
|
-
this.
|
|
629
|
+
this.log("debug", `sent rejoin for ${this.identity.avatarId}`);
|
|
607
630
|
return true;
|
|
608
631
|
}
|
|
609
632
|
|
|
@@ -628,7 +651,7 @@ export class KichiForwarderService {
|
|
|
628
651
|
if (!this.host) {
|
|
629
652
|
throw new Error("No Kichi host configured");
|
|
630
653
|
}
|
|
631
|
-
return path.join(
|
|
654
|
+
return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
|
|
632
655
|
}
|
|
633
656
|
|
|
634
657
|
private getWsUrl(): string {
|
|
@@ -647,14 +670,15 @@ export class KichiForwarderService {
|
|
|
647
670
|
|
|
648
671
|
private loadCurrentHost(): string | null {
|
|
649
672
|
try {
|
|
650
|
-
|
|
673
|
+
const statePath = this.getStatePath();
|
|
674
|
+
if (!fs.existsSync(statePath)) {
|
|
651
675
|
return null;
|
|
652
676
|
}
|
|
653
|
-
const data = JSON.parse(fs.readFileSync(
|
|
677
|
+
const data = JSON.parse(fs.readFileSync(statePath, "utf-8")) as { currentHost?: unknown };
|
|
654
678
|
if (typeof data.currentHost === "string" && data.currentHost.trim()) {
|
|
655
679
|
return data.currentHost;
|
|
656
680
|
}
|
|
657
|
-
throw new Error(`Invalid currentHost value in ${
|
|
681
|
+
throw new Error(`Invalid currentHost value in ${statePath}`);
|
|
658
682
|
} catch (error) {
|
|
659
683
|
throw new Error(`Failed to load current host: ${error}`);
|
|
660
684
|
}
|
|
@@ -666,17 +690,18 @@ export class KichiForwarderService {
|
|
|
666
690
|
currentHost: host,
|
|
667
691
|
llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
|
|
668
692
|
};
|
|
669
|
-
fs.mkdirSync(
|
|
670
|
-
fs.writeFileSync(
|
|
693
|
+
fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
|
|
694
|
+
fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
|
|
671
695
|
}
|
|
672
696
|
|
|
673
697
|
private readStateFile(): Partial<KichiState> | null {
|
|
674
|
-
|
|
698
|
+
const statePath = this.getStatePath();
|
|
699
|
+
if (!fs.existsSync(statePath)) {
|
|
675
700
|
return null;
|
|
676
701
|
}
|
|
677
|
-
const data = JSON.parse(fs.readFileSync(
|
|
702
|
+
const data = JSON.parse(fs.readFileSync(statePath, "utf-8")) as unknown;
|
|
678
703
|
if (!data || typeof data !== "object") {
|
|
679
|
-
throw new Error(`Invalid state payload in ${
|
|
704
|
+
throw new Error(`Invalid state payload in ${statePath}`);
|
|
680
705
|
}
|
|
681
706
|
return data as Partial<KichiState>;
|
|
682
707
|
}
|
|
@@ -699,4 +724,26 @@ export class KichiForwarderService {
|
|
|
699
724
|
this.joinResolve({ success: false, error: reason });
|
|
700
725
|
this.joinResolve = null;
|
|
701
726
|
}
|
|
727
|
+
|
|
728
|
+
private logPrefix(): string {
|
|
729
|
+
return `[kichi:${this.options.agentId}]`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private log(level: "debug" | "info" | "warn" | "error", message: string): void {
|
|
733
|
+
const formatted = `${this.logPrefix()} ${message}`;
|
|
734
|
+
switch (level) {
|
|
735
|
+
case "debug":
|
|
736
|
+
this.logger.debug(formatted);
|
|
737
|
+
return;
|
|
738
|
+
case "info":
|
|
739
|
+
this.logger.info(formatted);
|
|
740
|
+
return;
|
|
741
|
+
case "warn":
|
|
742
|
+
this.logger.warn(formatted);
|
|
743
|
+
return;
|
|
744
|
+
case "error":
|
|
745
|
+
this.logger.error(formatted);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
702
749
|
}
|