@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 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 the current host and `llmRuntimeEnabled`
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 { OpenClawPluginApi } from "openclaw/plugin-sdk";
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?.sendStatus(
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 isLlmRuntimeEnabled(): boolean {
237
- return readState().llmRuntimeEnabled;
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
- async function handleMessageReceivedHook(content: string): Promise<void> {
358
- const connected = service?.isConnected() ?? false;
359
- const hasIdentity = service?.hasValidIdentity() ?? false;
360
- pluginApi?.logger.info(`[kichi] message_received hook fired (connected=${connected}, hasIdentity=${hasIdentity})`);
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
- pluginApi?.logger.warn("[kichi] skipped message_received notify because service is not ready");
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
- pluginApi?.logger.info(`[kichi] sending message_received notify with preview: ${trimmed || "(empty)"}`);
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
- if (!service?.hasValidIdentity() || !service?.isConnected()) {
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, _ctx) => {
388
- if (!isLlmRuntimeEnabled()) {
389
- syncFixedStatus(FIXED_HOOK_STATUSES.beforeToolCall);
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
- pluginApi?.logger.info(
400
- `[kichi] agent_end hook fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
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
- pluginApi?.logger.info(`[kichi] sending before_send_message notify from agent_end with bubble: ${preview}`);
407
- service?.sendHookNotify("before_send_message", preview);
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(event.success ? FIXED_HOOK_STATUSES.agentEndSuccess : FIXED_HOOK_STATUSES.agentEndFailure);
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
- pluginApi = api;
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
- service = new KichiForwarderService(api.logger);
893
- return service.start();
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?.readSavedAvatarId() ?? undefined;
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?.join(avatarId, botName, bio, tags ?? []);
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?.leave();
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?.hasValidIdentity() || !service?.isConnected()) {
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?.hasValidIdentity() || !service?.isConnected()) {
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?.hasValidIdentity() || !service?.isConnected()) {
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?.hasValidIdentity() || !service?.isConnected()) {
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?.hasValidIdentity() || !service?.isConnected()) {
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?.hasValidIdentity() || !service?.isConnected()) {
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
  };
@@ -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.0-beta.9",
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.0-beta.9",
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. Run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
72
- 5. If step 4 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>`.
73
- 6. Ensure the plugin is installed and enabled.
74
- 7. If the plugin was newly installed or upgraded in this flow, re-check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
75
- 8. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md).
76
- 9. If `HEARTBEAT.md` was not updated successfully, report setup as incomplete and stop immediately. Do not continue to `kichi_status` or `kichi_join`.
77
- 10. Call `kichi_status`.
78
- 11. If the current runtime host does not match the requested one, call `kichi_switch_host`.
79
- 12. If `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
80
- 13. Call `kichi_status` again and confirm connection and auth state.
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 enabled
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(private logger: Logger) {}
74
+ constructor(
75
+ private logger: Logger,
76
+ private options: KichiForwarderServiceOptions,
77
+ ) {}
74
78
 
75
- async start(): Promise<void> {
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.logger.info("Kichi host is not configured yet; waiting for kichi_switch_host");
87
+ this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
84
88
  }
85
89
 
86
- async stop(): Promise<void> {
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.logger.warn(`Failed to parse leave response: ${e}`);
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.logger.info(`Connected to ${wsUrl} (${this.host})`);
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.logger.debug(`[kichi ws recv] ${data}`);
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.logger.warn(`Join failed: ${failure.error}`);
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.logger.info(`Joined as ${this.identity.avatarId}`);
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.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
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.logger.warn(`Leave failed: ${failure.error}`);
474
+ this.log("warn", `leave failed: ${failure.error}`);
452
475
  } else {
453
- this.logger.info("Left Kichi world");
476
+ this.log("info", "left Kichi world");
454
477
  }
455
478
  }
456
479
  } catch (e) {
457
- this.logger.warn(`Failed to parse message: ${e}`);
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.logger.warn(`Failed to load identity: ${e}`);
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.logger.error(`Failed to save identity: ${e}`);
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.logger.info("AuthKey cleared");
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.logger.debug(`Sent rejoin for ${this.identity.avatarId}`);
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(HOSTS_DIR, encodeURIComponent(this.host));
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
- if (!fs.existsSync(STATE_PATH)) {
673
+ const statePath = this.getStatePath();
674
+ if (!fs.existsSync(statePath)) {
651
675
  return null;
652
676
  }
653
- const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as { currentHost?: unknown };
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 ${STATE_PATH}`);
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(KICHI_WORLD_DIR, { recursive: true, mode: 0o700 });
670
- fs.writeFileSync(STATE_PATH, JSON.stringify(nextState, null, 2), { mode: 0o600 });
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
- if (!fs.existsSync(STATE_PATH)) {
698
+ const statePath = this.getStatePath();
699
+ if (!fs.existsSync(statePath)) {
675
700
  return null;
676
701
  }
677
- const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as unknown;
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 ${STATE_PATH}`);
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
  }
package/src/types.ts CHANGED
@@ -47,6 +47,9 @@ export type KichiIdentity = {
47
47
  };
48
48
 
49
49
  export type KichiConnectionStatus = {
50
+ agentId?: string;
51
+ runtimeDir?: string;
52
+ statePath?: string;
50
53
  host?: string;
51
54
  wsUrl?: string;
52
55
  identityPath?: string;