@yahaha-studio/kichi-forwarder 0.1.1-beta.1 → 0.1.1-beta.3
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/index.ts +99 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +5 -3
- package/skills/kichi-forwarder/references/heartbeat.md +25 -45
- package/skills/kichi-forwarder/references/install.md +4 -3
- package/src/runtime-manager.ts +43 -26
- package/src/service.ts +57 -14
package/index.ts
CHANGED
|
@@ -411,9 +411,66 @@ function notifyMessageReceived(
|
|
|
411
411
|
service.sendHookNotify("message_received", `"${trimmed}"`);
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
function trimOptionalString(value: unknown): string | undefined {
|
|
415
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readExtraStringField(source: unknown, key: string): string | undefined {
|
|
419
|
+
if (!isPlainObject(source)) {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return trimOptionalString(source[key]);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolveBeforeDispatchLocator(
|
|
426
|
+
event: { sessionKey?: string },
|
|
427
|
+
ctx: { sessionKey?: string },
|
|
428
|
+
): {
|
|
429
|
+
ctxAgentId?: string;
|
|
430
|
+
sessionKey?: string;
|
|
431
|
+
} {
|
|
432
|
+
const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
|
|
433
|
+
const sessionKey = trimOptionalString(ctx.sessionKey) ?? trimOptionalString(event.sessionKey);
|
|
434
|
+
return {
|
|
435
|
+
...(ctxAgentId ? { ctxAgentId } : {}),
|
|
436
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function resolveAgentHookLocator(ctx: {
|
|
441
|
+
agentId?: string;
|
|
442
|
+
sessionKey?: string;
|
|
443
|
+
}): {
|
|
444
|
+
agentId?: string;
|
|
445
|
+
ctxAgentId?: string;
|
|
446
|
+
sessionKey?: string;
|
|
447
|
+
} {
|
|
448
|
+
const agentId = trimOptionalString(ctx.agentId);
|
|
449
|
+
const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
|
|
450
|
+
const sessionKey = trimOptionalString(ctx.sessionKey);
|
|
451
|
+
return {
|
|
452
|
+
...(agentId ? { agentId } : {}),
|
|
453
|
+
...(ctxAgentId ? { ctxAgentId } : {}),
|
|
454
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function resolveToolLocator(ctx: OpenClawPluginToolContext): {
|
|
459
|
+
agentId?: string;
|
|
460
|
+
sessionKey?: string;
|
|
461
|
+
} {
|
|
462
|
+
const agentId = trimOptionalString(ctx.agentId);
|
|
463
|
+
const sessionKey = trimOptionalString(ctx.sessionKey);
|
|
464
|
+
return {
|
|
465
|
+
...(agentId ? { agentId } : {}),
|
|
466
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
414
470
|
function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntimeManager): void {
|
|
415
471
|
api.on("before_dispatch", (event, ctx) => {
|
|
416
|
-
const
|
|
472
|
+
const locator = resolveBeforeDispatchLocator(event, ctx);
|
|
473
|
+
const service = runtimeManager.getRuntime(locator);
|
|
417
474
|
if (!service) {
|
|
418
475
|
return;
|
|
419
476
|
}
|
|
@@ -428,7 +485,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
428
485
|
});
|
|
429
486
|
|
|
430
487
|
api.on("before_prompt_build", (_event, ctx) => {
|
|
431
|
-
const
|
|
488
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
489
|
+
const service = runtimeManager.getRuntime(locator);
|
|
432
490
|
if (!service?.hasValidIdentity() || !service.isConnected()) {
|
|
433
491
|
return;
|
|
434
492
|
}
|
|
@@ -445,7 +503,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
445
503
|
});
|
|
446
504
|
|
|
447
505
|
api.on("before_tool_call", (_event, ctx) => {
|
|
448
|
-
const
|
|
506
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
507
|
+
const service = runtimeManager.getRuntime(locator);
|
|
449
508
|
if (!service) {
|
|
450
509
|
return;
|
|
451
510
|
}
|
|
@@ -455,7 +514,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
455
514
|
});
|
|
456
515
|
|
|
457
516
|
api.on("agent_end", (event, ctx) => {
|
|
458
|
-
const
|
|
517
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
518
|
+
const service = runtimeManager.getRuntime(locator);
|
|
459
519
|
const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
|
|
460
520
|
api.logger.debug(
|
|
461
521
|
`[kichi:${service?.getAgentId() ?? "unknown"}] agent_end fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
|
|
@@ -913,6 +973,7 @@ function buildKichiIdlePlanDescription(): string {
|
|
|
913
973
|
"4. Make the full stage duration total exactly to the heartbeat interval, and assign each stage pomodoroPhase from the stage's actual role: focus for concentrated activity, shortBreak for short resets, longBreak for longer rests. Do not default the whole idle plan to none. Use none only for a stage that truly has no pomodoro role.",
|
|
914
974
|
"5. Choose stage actions that clearly match the stage purpose and the project.",
|
|
915
975
|
"6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
|
|
976
|
+
"7. If an idle plan is currently being carried out and the user asks about something from it, respond with an immersive in-universe depiction or draft instead of bluntly saying it is fake or nonexistent.",
|
|
916
977
|
"Use the same language as the current conversation for goal, purpose, bubble, and log.",
|
|
917
978
|
`stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
|
|
918
979
|
`sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
|
|
@@ -944,7 +1005,7 @@ function createAgentScopedTool(
|
|
|
944
1005
|
factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
|
|
945
1006
|
) {
|
|
946
1007
|
return (ctx: OpenClawPluginToolContext) => {
|
|
947
|
-
const service = runtimeManager.getRuntime(ctx
|
|
1008
|
+
const service = runtimeManager.getRuntime(resolveToolLocator(ctx));
|
|
948
1009
|
if (!service) {
|
|
949
1010
|
throw new Error("Failed to resolve agent-scoped Kichi runtime");
|
|
950
1011
|
}
|
|
@@ -952,13 +1013,30 @@ function createAgentScopedTool(
|
|
|
952
1013
|
};
|
|
953
1014
|
}
|
|
954
1015
|
|
|
1016
|
+
const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
|
|
1017
|
+
|
|
1018
|
+
type GlobalRuntimeManagerState = typeof globalThis & {
|
|
1019
|
+
[GLOBAL_RUNTIME_MANAGER_KEY]?: KichiRuntimeManager;
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeManager {
|
|
1023
|
+
const globalState = globalThis as GlobalRuntimeManagerState;
|
|
1024
|
+
const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
|
|
1025
|
+
if (existing) {
|
|
1026
|
+
return existing;
|
|
1027
|
+
}
|
|
1028
|
+
const runtimeManager = new KichiRuntimeManager(logger);
|
|
1029
|
+
globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
|
|
1030
|
+
return runtimeManager;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
955
1033
|
const plugin = {
|
|
956
1034
|
id: "kichi-forwarder",
|
|
957
1035
|
name: "Kichi Forwarder",
|
|
958
1036
|
configSchema: { parse },
|
|
959
1037
|
|
|
960
1038
|
register(api: OpenClawPluginApi) {
|
|
961
|
-
const runtimeManager =
|
|
1039
|
+
const runtimeManager = getRuntimeManager(api.logger);
|
|
962
1040
|
registerPluginHooks(api, runtimeManager);
|
|
963
1041
|
const musicTitleEnum = getMusicTitleEnum();
|
|
964
1042
|
|
|
@@ -966,10 +1044,14 @@ const plugin = {
|
|
|
966
1044
|
id: "kichi-forwarder",
|
|
967
1045
|
start: (ctx) => {
|
|
968
1046
|
parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
|
|
969
|
-
runtimeManager.
|
|
1047
|
+
runtimeManager.initializeStartupRuntimes();
|
|
970
1048
|
},
|
|
971
1049
|
stop: () => {
|
|
972
1050
|
runtimeManager.stopAll();
|
|
1051
|
+
const globalState = globalThis as GlobalRuntimeManagerState;
|
|
1052
|
+
if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
|
|
1053
|
+
delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
|
|
1054
|
+
}
|
|
973
1055
|
},
|
|
974
1056
|
});
|
|
975
1057
|
|
|
@@ -1031,7 +1113,14 @@ const plugin = {
|
|
|
1031
1113
|
},
|
|
1032
1114
|
})));
|
|
1033
1115
|
|
|
1034
|
-
api.registerTool(
|
|
1116
|
+
api.registerTool((ctx: OpenClawPluginToolContext) => {
|
|
1117
|
+
const locator = resolveToolLocator(ctx);
|
|
1118
|
+
const agentId = runtimeManager.resolveRuntimeAgentId(locator);
|
|
1119
|
+
if (!agentId) {
|
|
1120
|
+
throw new Error("Failed to resolve agent-scoped Kichi runtime");
|
|
1121
|
+
}
|
|
1122
|
+
const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
|
|
1123
|
+
return ({
|
|
1035
1124
|
name: "kichi_switch_host",
|
|
1036
1125
|
description:
|
|
1037
1126
|
"Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
|
|
@@ -1058,7 +1147,8 @@ const plugin = {
|
|
|
1058
1147
|
status,
|
|
1059
1148
|
};
|
|
1060
1149
|
},
|
|
1061
|
-
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1062
1152
|
|
|
1063
1153
|
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1064
1154
|
name: "kichi_rejoin",
|
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.1-beta.
|
|
5
|
+
"version": "0.1.1-beta.3",
|
|
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.1-beta.
|
|
3
|
+
"version": "0.1.1-beta.3",
|
|
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",
|
|
@@ -66,9 +66,10 @@ Install/onboarding requests are the exception: follow `install.md` first.
|
|
|
66
66
|
|
|
67
67
|
1. If connection or identity is unknown, call `kichi_status` first.
|
|
68
68
|
2. If the requested host differs from the current host, call `kichi_switch_host`.
|
|
69
|
-
3. If
|
|
70
|
-
4.
|
|
71
|
-
5.
|
|
69
|
+
3. If the requested `avatarId` differs from the current host's connected `avatarId`, call `kichi_leave` first when the old avatar is still joined, then call `kichi_join` with the requested `avatarId`.
|
|
70
|
+
4. Otherwise, if no `authKey` is available, call `kichi_join`.
|
|
71
|
+
5. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
|
|
72
|
+
6. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools only after status is ready.
|
|
72
73
|
|
|
73
74
|
## Tools
|
|
74
75
|
|
|
@@ -82,6 +83,7 @@ kichi_join(avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<fro
|
|
|
82
83
|
- `bio`: required
|
|
83
84
|
- `avatarId`: optional. If omitted, the tool reads `avatarId` from the current host's `identity.json`. If missing, the call fails.
|
|
84
85
|
- `tags`: optional string list. Empty strings are ignored and duplicates are removed. If omitted, the join payload sends `[]`.
|
|
86
|
+
- If the current host is still joined with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with the new `avatarId`.
|
|
85
87
|
|
|
86
88
|
### kichi_switch_host
|
|
87
89
|
|
|
@@ -32,63 +32,43 @@ If user wants recurring note board checks:
|
|
|
32
32
|
|
|
33
33
|
## Definitions
|
|
34
34
|
|
|
35
|
+
All query fields below (`remaining`, `dailyLimit`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
|
|
36
|
+
|
|
35
37
|
- `Recent window`: `min(24 hours, time since last heartbeat if known)`.
|
|
36
|
-
- `High-priority note`: recent note
|
|
37
|
-
- `isFromOwner: true`, or
|
|
38
|
-
- explicitly addressed to you, or
|
|
39
|
-
- a direct question/request requiring your response.
|
|
38
|
+
- `High-priority note`: recent note where `isFromOwner: true`, explicitly addressed to you, or a direct question/request requiring your response.
|
|
40
39
|
- `Meaningful standalone note`: follows a two-tier priority:
|
|
41
|
-
1. **Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
|
|
42
|
-
2. **Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `Idle behavior plan`: on every heartbeat run, plan what you would do on your own across the full heartbeat interval, then send it with `kichi_idle_plan`. The plan must follow the current pomodoro rhythm and its total duration must exactly equal the heartbeat interval.
|
|
50
|
-
- `Idle plan reference rule`: use the previous `idlePlan` only as optional reference.
|
|
51
|
-
- `Idle plan now-rule`: choose what you would genuinely do now, in a way that matches your personality and interests.
|
|
52
|
-
- `Idle plan tool rule`: when calling `kichi_idle_plan`, follow that tool's schema and description for how to shape the goal, stages, phases, actions, bubbles, and language.
|
|
53
|
-
|
|
54
|
-
## Note Triage Order
|
|
55
|
-
|
|
56
|
-
Process recent notes in this order:
|
|
40
|
+
1. **Tier-1 — Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
|
|
41
|
+
2. **Tier-2 — Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
|
|
42
|
+
|
|
43
|
+
## Note Rules
|
|
44
|
+
|
|
45
|
+
Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standalone).
|
|
46
|
+
|
|
47
|
+
**Triage order** — scan recent-window notes and pick at most one reply target:
|
|
57
48
|
|
|
58
49
|
1. Owner notes or notes clearly addressed to you.
|
|
59
50
|
2. Direct questions or explicit requests.
|
|
60
51
|
3. Other recent notes where one short response adds clear value.
|
|
61
|
-
4. If no reply target was selected, apply `Standalone trigger` (always for tier-1; about 50% coin-flip for tier-2).
|
|
62
|
-
|
|
63
|
-
Skip a note when any is true:
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
- `isCreatedByCurrentAgent: true`
|
|
67
|
-
- same context already answered
|
|
68
|
-
- low-value ambient chatter
|
|
53
|
+
Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, same context already answered, or low-value ambient chatter.
|
|
69
54
|
|
|
70
|
-
|
|
55
|
+
**Standalone gating** — applies when `remaining > 0` and no reply target was selected, OR after a reply when `remaining` still allows one more:
|
|
71
56
|
|
|
72
|
-
1
|
|
73
|
-
2
|
|
57
|
+
- Tier-1 content exists → always create 1 standalone note.
|
|
58
|
+
- Tier-2 only → flip a mental coin (about 50% chance); skip on tails.
|
|
59
|
+
- Notes list empty and `remaining > 0` → create 1 standalone note.
|
|
60
|
+
- In both tiers, skip if it would clearly repeat your very recent own note.
|
|
74
61
|
|
|
75
62
|
## Heartbeat Workflow
|
|
76
63
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
7. If a reply target was selected, create one reply note in `To {authorName}, ...` format.
|
|
86
|
-
8. If `remaining > 0` and no reply note was created in this run, apply `Standalone trigger` gating: always create when tier-1 content exists; for tier-2 (casual chat only), flip a mental coin (about 50%) and skip the note if tails.
|
|
87
|
-
9. If `remaining > 0` and a reply note was created in this run, you may still create one additional meaningful standalone note when non-repetitive. The same tier priority applies.
|
|
88
|
-
10. Plan the avatar's full heartbeat-interval idle routine for the full heartbeat interval.
|
|
89
|
-
11. Call `kichi_idle_plan`, using the previous `idlePlan` only as optional reference.
|
|
90
|
-
12. Make it a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, and total exactly to the heartbeat interval.
|
|
91
|
-
13. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
64
|
+
1. Call `kichi_query_status`. If it fails, report error and stop.
|
|
65
|
+
2. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools in this run. Reply `HEARTBEAT_OK` and stop.
|
|
66
|
+
3. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once following `Music Album Policy`. If `true`, skip.
|
|
67
|
+
4. If `remaining == 0`, skip note creation and go to step 7.
|
|
68
|
+
5. Scan recent notes and pick at most one reply target per `Note Rules`. If found, create one reply note in `To {authorName}, ...` format.
|
|
69
|
+
6. Apply `Standalone gating` from `Note Rules`.
|
|
70
|
+
7. Call `kichi_idle_plan`: plan a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, totaling exactly to the heartbeat interval. Use the previous `idlePlan` only as optional reference. Follow that tool's schema and description for goal, stages, phases, actions, bubbles, and language.
|
|
71
|
+
8. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
92
72
|
|
|
93
73
|
## HEARTBEAT.md Snippet
|
|
94
74
|
|
|
@@ -18,7 +18,7 @@ Persist runtime state to the current agent's `state.json`:
|
|
|
18
18
|
}
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
If the current host has no saved `avatarId` yet, save it to the current agent's host-specific `identity.json` before using `kichi_join`:
|
|
22
22
|
|
|
23
23
|
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
|
|
24
24
|
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
|
|
@@ -80,8 +80,9 @@ When the user asks with one of the commands above, execute in this fixed order:
|
|
|
80
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
81
|
14. Call `kichi_status`.
|
|
82
82
|
15. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
|
|
83
|
-
16. If `
|
|
84
|
-
17.
|
|
83
|
+
16. If the current host is still connected with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
84
|
+
17. Otherwise, if `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
85
|
+
18. Call `kichi_status` again and confirm connection and auth state.
|
|
85
86
|
|
|
86
87
|
## Required Post-install Integration
|
|
87
88
|
|
package/src/runtime-manager.ts
CHANGED
|
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
5
|
-
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
6
5
|
import { KichiForwarderService } from "./service.js";
|
|
7
6
|
|
|
8
7
|
const OPENCLAW_HOME_DIR = path.join(os.homedir(), ".openclaw");
|
|
@@ -15,37 +14,43 @@ const LEGACY_MIGRATION_AGENT_ID = "main";
|
|
|
15
14
|
|
|
16
15
|
type AgentLocator = {
|
|
17
16
|
agentId?: string;
|
|
17
|
+
ctxAgentId?: string;
|
|
18
18
|
sessionKey?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
type GetRuntimeOptions = {
|
|
22
|
-
createIfMissing?: boolean;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
21
|
export class KichiRuntimeManager {
|
|
26
22
|
private services = new Map<string, KichiForwarderService>();
|
|
27
23
|
|
|
28
24
|
constructor(private logger: Logger) {}
|
|
29
25
|
|
|
30
|
-
getRuntime(locator: AgentLocator
|
|
26
|
+
getRuntime(locator: AgentLocator): KichiForwarderService | null {
|
|
31
27
|
const agentId = this.resolveAgentId(locator);
|
|
32
28
|
if (!agentId) {
|
|
33
29
|
return null;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
return this.services.get(agentId) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resolveRuntimeAgentId(locator: AgentLocator): string | null {
|
|
36
|
+
return this.resolveAgentId(locator);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
createRuntimeForAgent(agentId: string): KichiForwarderService {
|
|
40
|
+
const normalizedAgentId = this.normalizeAgentId(agentId);
|
|
41
|
+
if (!normalizedAgentId) {
|
|
42
|
+
throw new Error("Cannot create Kichi runtime without a valid agentId");
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const existing = this.services.get(normalizedAgentId);
|
|
46
|
+
if (existing) {
|
|
47
|
+
return existing;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
return this.createRuntime(
|
|
50
|
+
return this.createRuntime(normalizedAgentId);
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
53
|
+
initializeStartupRuntimes(): void {
|
|
49
54
|
this.migrateRuntimeStorage();
|
|
50
55
|
|
|
51
56
|
const rootDir = CANONICAL_AGENT_ROOT_DIR;
|
|
@@ -80,24 +85,36 @@ export class KichiRuntimeManager {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
private resolveAgentId(locator: AgentLocator): string | null {
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
const directAgentId = this.normalizeAgentId(locator.ctxAgentId) ?? this.normalizeAgentId(locator.agentId);
|
|
89
|
+
const sessionAgentId =
|
|
90
|
+
typeof locator.sessionKey === "string" && locator.sessionKey.trim()
|
|
91
|
+
? this.parseAgentIdFromSessionKey(locator.sessionKey)
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
if (sessionAgentId) {
|
|
95
|
+
if (directAgentId && directAgentId !== sessionAgentId) {
|
|
96
|
+
this.logger.error(
|
|
97
|
+
`[kichi] runtime scope mismatch: directAgentId=${directAgentId} sessionAgentId=${sessionAgentId} sessionKey=${locator.sessionKey}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
this.logger.debug(`[kichi] resolved agent runtime from sessionKey: ${sessionAgentId}`);
|
|
101
|
+
return sessionAgentId;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
104
|
+
return directAgentId;
|
|
105
|
+
}
|
|
90
106
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
|
|
94
|
-
} catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
107
|
+
private normalizeAgentId(value: unknown): string | null {
|
|
108
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
private
|
|
100
|
-
|
|
111
|
+
private parseAgentIdFromSessionKey(sessionKey: string): string | null {
|
|
112
|
+
const trimmed = sessionKey.trim();
|
|
113
|
+
const match = /^agent:([^:]+):/i.exec(trimmed);
|
|
114
|
+
if (!match) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return this.normalizeAgentId(match[1]);
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
private migrateRuntimeStorage(): void {
|
package/src/service.ts
CHANGED
|
@@ -54,10 +54,13 @@ type KichiForwarderServiceOptions = {
|
|
|
54
54
|
runtimeDir: string;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
type ConnectReason = "startup" | "switch_host" | "reconnect";
|
|
58
|
+
|
|
57
59
|
export class KichiForwarderService {
|
|
58
60
|
private ws: WebSocket | null = null;
|
|
59
61
|
private stopped = false;
|
|
60
62
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
63
|
+
private joinTimeout: NodeJS.Timeout | null = null;
|
|
61
64
|
private identity: KichiIdentity | null = null;
|
|
62
65
|
private host: string | null = null;
|
|
63
66
|
private joinResolve: ((result: JoinResult) => void) | null = null;
|
|
@@ -81,7 +84,7 @@ export class KichiForwarderService {
|
|
|
81
84
|
this.identity = this.host ? this.loadIdentity() : null;
|
|
82
85
|
this.stopped = false;
|
|
83
86
|
if (this.host) {
|
|
84
|
-
this.connect();
|
|
87
|
+
this.connect("startup");
|
|
85
88
|
return;
|
|
86
89
|
}
|
|
87
90
|
this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
|
|
@@ -104,7 +107,7 @@ export class KichiForwarderService {
|
|
|
104
107
|
this.failPendingJoin(`Kichi websocket switched to ${host}`);
|
|
105
108
|
this.closeSocket();
|
|
106
109
|
if (!this.stopped) {
|
|
107
|
-
this.connect();
|
|
110
|
+
this.connect("switch_host");
|
|
108
111
|
}
|
|
109
112
|
return this.getConnectionStatus();
|
|
110
113
|
}
|
|
@@ -118,7 +121,14 @@ export class KichiForwarderService {
|
|
|
118
121
|
if (!this.host) {
|
|
119
122
|
return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
|
|
120
123
|
}
|
|
124
|
+
if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
121
130
|
return new Promise((resolve) => {
|
|
131
|
+
this.failPendingJoin("Kichi join superseded by a new join request");
|
|
122
132
|
this.identity = { avatarId };
|
|
123
133
|
this.saveIdentity();
|
|
124
134
|
this.joinResolve = resolve;
|
|
@@ -129,9 +139,10 @@ export class KichiForwarderService {
|
|
|
129
139
|
} else {
|
|
130
140
|
this.ws?.once("open", sendJoin);
|
|
131
141
|
}
|
|
132
|
-
setTimeout(() => {
|
|
142
|
+
this.joinTimeout = setTimeout(() => {
|
|
133
143
|
if (this.joinResolve) {
|
|
134
144
|
this.joinResolve = null;
|
|
145
|
+
this.clearJoinTimeout();
|
|
135
146
|
resolve({ success: false, error: "Timed out waiting for join_ack" });
|
|
136
147
|
}
|
|
137
148
|
}, 10000);
|
|
@@ -344,12 +355,18 @@ export class KichiForwarderService {
|
|
|
344
355
|
};
|
|
345
356
|
}
|
|
346
357
|
|
|
347
|
-
this.
|
|
348
|
-
|
|
358
|
+
if (this.reconnectTimeout) {
|
|
359
|
+
return {
|
|
360
|
+
accepted: true,
|
|
361
|
+
mode: "reconnecting",
|
|
362
|
+
message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
349
366
|
return {
|
|
350
|
-
accepted:
|
|
351
|
-
mode: "
|
|
352
|
-
message: "
|
|
367
|
+
accepted: false,
|
|
368
|
+
mode: "unavailable",
|
|
369
|
+
message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
|
|
353
370
|
};
|
|
354
371
|
}
|
|
355
372
|
|
|
@@ -409,12 +426,18 @@ export class KichiForwarderService {
|
|
|
409
426
|
});
|
|
410
427
|
}
|
|
411
428
|
|
|
412
|
-
private connect(): void {
|
|
429
|
+
private connect(reason: ConnectReason): void {
|
|
413
430
|
if (this.stopped || !this.host) return;
|
|
431
|
+
if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
|
|
432
|
+
this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
414
435
|
|
|
436
|
+
this.clearReconnectTimeout();
|
|
415
437
|
const wsUrl = this.getWsUrl();
|
|
416
438
|
const ws = new WebSocket(wsUrl);
|
|
417
439
|
this.ws = ws;
|
|
440
|
+
this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
|
|
418
441
|
|
|
419
442
|
ws.on("open", () => {
|
|
420
443
|
if (this.ws !== ws) return;
|
|
@@ -431,15 +454,16 @@ export class KichiForwarderService {
|
|
|
431
454
|
if (this.ws !== ws) return;
|
|
432
455
|
this.ws = null;
|
|
433
456
|
this.rejectPendingRequests("Kichi websocket closed");
|
|
457
|
+
this.failPendingJoin("Kichi websocket closed");
|
|
434
458
|
if (!this.stopped) {
|
|
435
|
-
this.
|
|
436
|
-
this.reconnectTimeout = null;
|
|
437
|
-
this.connect();
|
|
438
|
-
}, 2000);
|
|
459
|
+
this.scheduleReconnect();
|
|
439
460
|
}
|
|
440
461
|
});
|
|
441
462
|
|
|
442
|
-
ws.on("error", () => {
|
|
463
|
+
ws.on("error", (error) => {
|
|
464
|
+
if (this.ws !== ws) return;
|
|
465
|
+
this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
466
|
+
});
|
|
443
467
|
}
|
|
444
468
|
|
|
445
469
|
private handleMessage(data: string): void {
|
|
@@ -454,6 +478,7 @@ export class KichiForwarderService {
|
|
|
454
478
|
this.log("warn", `join failed: ${failure.error}`);
|
|
455
479
|
this.joinResolve?.(failure);
|
|
456
480
|
this.joinResolve = null;
|
|
481
|
+
this.clearJoinTimeout();
|
|
457
482
|
return;
|
|
458
483
|
}
|
|
459
484
|
|
|
@@ -464,6 +489,7 @@ export class KichiForwarderService {
|
|
|
464
489
|
}
|
|
465
490
|
this.joinResolve?.({ success: true, authKey: joinAck.authKey });
|
|
466
491
|
this.joinResolve = null;
|
|
492
|
+
this.clearJoinTimeout();
|
|
467
493
|
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
468
494
|
this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
|
|
469
495
|
this.clearAuthKey();
|
|
@@ -712,6 +738,22 @@ export class KichiForwarderService {
|
|
|
712
738
|
this.reconnectTimeout = null;
|
|
713
739
|
}
|
|
714
740
|
|
|
741
|
+
private clearJoinTimeout(): void {
|
|
742
|
+
if (!this.joinTimeout) return;
|
|
743
|
+
clearTimeout(this.joinTimeout);
|
|
744
|
+
this.joinTimeout = null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private scheduleReconnect(): void {
|
|
748
|
+
if (this.reconnectTimeout || this.stopped) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
752
|
+
this.reconnectTimeout = null;
|
|
753
|
+
this.connect("reconnect");
|
|
754
|
+
}, 2000);
|
|
755
|
+
}
|
|
756
|
+
|
|
715
757
|
private closeSocket(): void {
|
|
716
758
|
const socket = this.ws;
|
|
717
759
|
this.ws = null;
|
|
@@ -723,6 +765,7 @@ export class KichiForwarderService {
|
|
|
723
765
|
if (!this.joinResolve) return;
|
|
724
766
|
this.joinResolve({ success: false, error: reason });
|
|
725
767
|
this.joinResolve = null;
|
|
768
|
+
this.clearJoinTimeout();
|
|
726
769
|
}
|
|
727
770
|
|
|
728
771
|
private logPrefix(): string {
|