@yahaha-studio/kichi-forwarder 0.0.1-alpha.25
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/.claude/settings.local.json +18 -0
- package/.github/workflows/static.yml +43 -0
- package/index.ts +845 -0
- package/openclaw.plugin.json +25 -0
- package/package.json +24 -0
- package/skills/kichi-forwarder/.nojekyll +0 -0
- package/skills/kichi-forwarder/SKILL.md +243 -0
- package/skills/kichi-forwarder/references/error.md +10 -0
- package/skills/kichi-forwarder/references/heartbeat.md +83 -0
- package/skills/kichi-forwarder/references/install.md +51 -0
- package/src/config.ts +9 -0
- package/src/service.ts +434 -0
- package/src/types.ts +210 -0
package/index.ts
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { parse } from "./src/config.js";
|
|
6
|
+
import { KichiForwarderService } from "./src/service.js";
|
|
7
|
+
import type {
|
|
8
|
+
ActionResult,
|
|
9
|
+
ClockAction,
|
|
10
|
+
ClockConfig,
|
|
11
|
+
CreateNotesBoardNote,
|
|
12
|
+
CreateNotesBoardNoteResultPayload,
|
|
13
|
+
KichiRuntimeConfig,
|
|
14
|
+
KichiForwarderConfig,
|
|
15
|
+
PomodoroPhase,
|
|
16
|
+
PoseType,
|
|
17
|
+
} from "./src/types.js";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_ACTIONS: KichiRuntimeConfig["actions"] = {
|
|
20
|
+
stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
|
|
21
|
+
sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
|
|
22
|
+
lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
|
|
23
|
+
floor: ["Seiza", "Cross Legged", "Knee Hug"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_RUNTIME_CONFIG: KichiRuntimeConfig = {
|
|
27
|
+
actions: DEFAULT_ACTIONS,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
31
|
+
const RUNTIME_CONFIG_PATH = path.join(KICHI_WORLD_DIR, "kichi-runtime-config.json");
|
|
32
|
+
const LEGACY_SKILLS_CONFIG_PATH = path.join(KICHI_WORLD_DIR, "skills-config.json");
|
|
33
|
+
const IDENTITY_PATH = path.join(KICHI_WORLD_DIR, "identity.json");
|
|
34
|
+
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
35
|
+
const MESSAGE_RECEIVED_BUBBLES = [
|
|
36
|
+
"(`・ω・�?�?,
|
|
37
|
+
"( ̄^�?�?,
|
|
38
|
+
"(〃・ิ‿・�?�?,
|
|
39
|
+
"(≧∀�?�?,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
let cachedConfig: KichiRuntimeConfig | null = null;
|
|
43
|
+
let cachedConfigMtime = 0;
|
|
44
|
+
let cachedConfigPath = "";
|
|
45
|
+
let service: KichiForwarderService | null = null;
|
|
46
|
+
let pluginApi: OpenClawPluginApi | null = null;
|
|
47
|
+
let lastKnownStatus: ActionResult = {
|
|
48
|
+
poseType: "sit",
|
|
49
|
+
action: DEFAULT_ACTIONS.sit[0],
|
|
50
|
+
bubble: "Working",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function sanitizeActions(value: unknown, fallback: string[]): string[] {
|
|
54
|
+
if (!Array.isArray(value)) {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
const actions = value.filter(
|
|
58
|
+
(item): item is string => typeof item === "string" && item.trim().length > 0,
|
|
59
|
+
);
|
|
60
|
+
return actions.length > 0 ? actions : fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeRuntimeConfig(value: unknown): KichiRuntimeConfig {
|
|
64
|
+
const raw = value && typeof value === "object" ? (value as Partial<KichiRuntimeConfig>) : {};
|
|
65
|
+
const actions = raw.actions;
|
|
66
|
+
return {
|
|
67
|
+
actions: {
|
|
68
|
+
stand: sanitizeActions(actions?.stand, DEFAULT_ACTIONS.stand),
|
|
69
|
+
sit: sanitizeActions(actions?.sit, DEFAULT_ACTIONS.sit),
|
|
70
|
+
lay: sanitizeActions(actions?.lay, DEFAULT_ACTIONS.lay),
|
|
71
|
+
floor: sanitizeActions(actions?.floor, DEFAULT_ACTIONS.floor),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveRuntimeConfigPath(): string | null {
|
|
77
|
+
if (fs.existsSync(RUNTIME_CONFIG_PATH)) {
|
|
78
|
+
return RUNTIME_CONFIG_PATH;
|
|
79
|
+
}
|
|
80
|
+
if (fs.existsSync(LEGACY_SKILLS_CONFIG_PATH)) {
|
|
81
|
+
return LEGACY_SKILLS_CONFIG_PATH;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function updateCachedRuntimeConfig(config: KichiRuntimeConfig, sourcePath: string | null): KichiRuntimeConfig {
|
|
87
|
+
cachedConfig = config;
|
|
88
|
+
cachedConfigPath = sourcePath ?? "";
|
|
89
|
+
try {
|
|
90
|
+
cachedConfigMtime = sourcePath && fs.existsSync(sourcePath)
|
|
91
|
+
? fs.statSync(sourcePath).mtimeMs
|
|
92
|
+
: 0;
|
|
93
|
+
} catch {
|
|
94
|
+
cachedConfigMtime = 0;
|
|
95
|
+
}
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function loadRuntimeConfig(): KichiRuntimeConfig {
|
|
100
|
+
try {
|
|
101
|
+
const configPath = resolveRuntimeConfigPath();
|
|
102
|
+
if (configPath) {
|
|
103
|
+
const stat = fs.statSync(configPath);
|
|
104
|
+
if (configPath !== cachedConfigPath || stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
|
|
105
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
106
|
+
updateCachedRuntimeConfig(normalizeRuntimeConfig(JSON.parse(raw)), configPath);
|
|
107
|
+
const sourceName = path.basename(configPath);
|
|
108
|
+
pluginApi?.logger.debug(`[kichi] loaded runtime config from ${sourceName}`);
|
|
109
|
+
}
|
|
110
|
+
return cachedConfig!;
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
pluginApi?.logger.warn(`[kichi] failed to load runtime config: ${error}`);
|
|
114
|
+
}
|
|
115
|
+
return updateCachedRuntimeConfig(DEFAULT_RUNTIME_CONFIG, null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function truncateLog(text: string, maxLen = 150): string {
|
|
119
|
+
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function truncateInline(text: string, maxLen: number): string {
|
|
123
|
+
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function prefixLogTimestamp(log: string): string {
|
|
127
|
+
const trimmed = log.trim();
|
|
128
|
+
if (!trimmed) {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
const timestamp = new Date().toISOString().replace("T", " ");
|
|
132
|
+
return `[${timestamp}] ${trimmed}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stringifyParamsForLog(value: unknown, maxLen = 220): string {
|
|
136
|
+
if (value === undefined) {
|
|
137
|
+
return "{}";
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return truncateInline(JSON.stringify(value), maxLen);
|
|
141
|
+
} catch {
|
|
142
|
+
return truncateInline(String(value), maxLen);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rememberStatus(status: ActionResult): void {
|
|
147
|
+
lastKnownStatus = {
|
|
148
|
+
poseType: status.poseType,
|
|
149
|
+
action: status.action,
|
|
150
|
+
bubble: status.bubble.trim() || status.action,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sendStatusAndRemember(status: ActionResult, log: string): void {
|
|
155
|
+
rememberStatus(status);
|
|
156
|
+
service?.sendStatus(
|
|
157
|
+
status.poseType,
|
|
158
|
+
status.action,
|
|
159
|
+
status.bubble || status.action,
|
|
160
|
+
prefixLogTimestamp(log),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function forwardToolCallLog(toolName: string, params: unknown, agentId?: string): void {
|
|
165
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!toolName || toolName === "kichi_action") {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const paramsText = stringifyParamsForLog(params);
|
|
174
|
+
const bubble = lastKnownStatus.bubble.trim() || lastKnownStatus.action;
|
|
175
|
+
const prefix = typeof agentId === "string" && agentId.trim() ? `[${agentId.trim()}] ` : "";
|
|
176
|
+
const log = truncateLog(`${prefix}exec tool: ${toolName}, params: ${paramsText}`, 300);
|
|
177
|
+
service.sendStatus(lastKnownStatus.poseType, lastKnownStatus.action, bubble, prefixLogTimestamp(log));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveStatusSourceId(ctx?: { agentId?: string; sessionKey?: string }): string | undefined {
|
|
181
|
+
if (typeof ctx?.agentId === "string" && ctx.agentId.trim()) {
|
|
182
|
+
return ctx.agentId.trim();
|
|
183
|
+
}
|
|
184
|
+
if (typeof ctx?.sessionKey === "string" && ctx.sessionKey.trim()) {
|
|
185
|
+
return ctx.sessionKey.trim();
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function handleMessageReceivedHook(
|
|
191
|
+
event: any,
|
|
192
|
+
ctx?: { agentId?: string; sessionKey?: string },
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sourceId = resolveStatusSourceId(ctx);
|
|
199
|
+
const content =
|
|
200
|
+
typeof event?.content === "string" && event.content.trim()
|
|
201
|
+
? event.content.trim()
|
|
202
|
+
: JSON.stringify(event ?? "new message");
|
|
203
|
+
const bubble = pickRandomAction(MESSAGE_RECEIVED_BUBBLES);
|
|
204
|
+
const context = `${sourceId ? `[${sourceId}] ` : ""}Received: ${content}; bubble=${bubble}`;
|
|
205
|
+
service.sendStatus("", "", bubble, prefixLogTimestamp(truncateLog(context)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
209
|
+
api.on("before_prompt_build", () => {
|
|
210
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
prependContext: buildKichiPrompt(),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
219
|
+
forwardToolCallLog(event.toolName, event.params, ctx?.agentId);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
api.on("message_received", async (event, ctx) => {
|
|
223
|
+
await handleMessageReceivedHook(event, ctx);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
228
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
232
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
236
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isClockAction(value: unknown): value is ClockAction {
|
|
240
|
+
return ["set", "stop"].includes(String(value));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isPomodoroPhase(value: unknown): value is PomodoroPhase {
|
|
244
|
+
return ["kichiing", "shortBreak", "longBreak"].includes(String(value));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getPomodoroPhaseDuration(
|
|
248
|
+
phase: PomodoroPhase,
|
|
249
|
+
kichiSeconds: number,
|
|
250
|
+
shortBreakSeconds: number,
|
|
251
|
+
longBreakSeconds: number,
|
|
252
|
+
): number {
|
|
253
|
+
if (phase === "shortBreak") {
|
|
254
|
+
return shortBreakSeconds;
|
|
255
|
+
}
|
|
256
|
+
if (phase === "longBreak") {
|
|
257
|
+
return longBreakSeconds;
|
|
258
|
+
}
|
|
259
|
+
return kichiSeconds;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeClockConfig(value: unknown): { clock?: ClockConfig; error?: string } {
|
|
263
|
+
if (!isPlainObject(value)) {
|
|
264
|
+
return { error: "clock must be an object" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mode = value.mode;
|
|
268
|
+
if (!["pomodoro", "countDown", "countUp"].includes(String(mode))) {
|
|
269
|
+
return { error: "clock.mode must be pomodoro, countDown, or countUp" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const running = typeof value.running === "boolean" ? value.running : true;
|
|
273
|
+
|
|
274
|
+
if (mode === "pomodoro") {
|
|
275
|
+
const kichiSeconds = value.kichiSeconds;
|
|
276
|
+
const shortBreakSeconds = value.shortBreakSeconds;
|
|
277
|
+
const longBreakSeconds = value.longBreakSeconds;
|
|
278
|
+
const sessionCount = value.sessionCount;
|
|
279
|
+
const currentSession = value.currentSession ?? 1;
|
|
280
|
+
const phase = value.phase ?? "kichiing";
|
|
281
|
+
|
|
282
|
+
if (!isPositiveInteger(kichiSeconds)) {
|
|
283
|
+
return { error: "clock.kichiSeconds must be a positive integer" };
|
|
284
|
+
}
|
|
285
|
+
if (!isPositiveInteger(shortBreakSeconds)) {
|
|
286
|
+
return { error: "clock.shortBreakSeconds must be a positive integer" };
|
|
287
|
+
}
|
|
288
|
+
if (!isPositiveInteger(longBreakSeconds)) {
|
|
289
|
+
return { error: "clock.longBreakSeconds must be a positive integer" };
|
|
290
|
+
}
|
|
291
|
+
if (!isPositiveInteger(sessionCount)) {
|
|
292
|
+
return { error: "clock.sessionCount must be a positive integer" };
|
|
293
|
+
}
|
|
294
|
+
if (!isPositiveInteger(currentSession)) {
|
|
295
|
+
return { error: "clock.currentSession must be a positive integer" };
|
|
296
|
+
}
|
|
297
|
+
if (currentSession > sessionCount) {
|
|
298
|
+
return { error: "clock.currentSession cannot be greater than clock.sessionCount" };
|
|
299
|
+
}
|
|
300
|
+
if (!isPomodoroPhase(phase)) {
|
|
301
|
+
return { error: "clock.phase must be kichiing, shortBreak, or longBreak" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const defaultRemainingSeconds = getPomodoroPhaseDuration(
|
|
305
|
+
phase,
|
|
306
|
+
kichiSeconds,
|
|
307
|
+
shortBreakSeconds,
|
|
308
|
+
longBreakSeconds,
|
|
309
|
+
);
|
|
310
|
+
const remainingSeconds = value.remainingSeconds ?? defaultRemainingSeconds;
|
|
311
|
+
if (!isNonNegativeInteger(remainingSeconds)) {
|
|
312
|
+
return { error: "clock.remainingSeconds must be a non-negative integer" };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
clock: {
|
|
317
|
+
mode: "pomodoro",
|
|
318
|
+
running,
|
|
319
|
+
kichiSeconds,
|
|
320
|
+
shortBreakSeconds,
|
|
321
|
+
longBreakSeconds,
|
|
322
|
+
sessionCount,
|
|
323
|
+
currentSession,
|
|
324
|
+
phase,
|
|
325
|
+
remainingSeconds,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (mode === "countDown") {
|
|
331
|
+
const durationSeconds = value.durationSeconds;
|
|
332
|
+
if (!isPositiveInteger(durationSeconds)) {
|
|
333
|
+
return { error: "clock.durationSeconds must be a positive integer" };
|
|
334
|
+
}
|
|
335
|
+
const remainingSeconds = value.remainingSeconds ?? durationSeconds;
|
|
336
|
+
if (!isNonNegativeInteger(remainingSeconds)) {
|
|
337
|
+
return { error: "clock.remainingSeconds must be a non-negative integer" };
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
clock: {
|
|
341
|
+
mode: "countDown",
|
|
342
|
+
running,
|
|
343
|
+
durationSeconds,
|
|
344
|
+
remainingSeconds,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const elapsedSeconds = value.elapsedSeconds ?? 0;
|
|
350
|
+
if (!isNonNegativeInteger(elapsedSeconds)) {
|
|
351
|
+
return { error: "clock.elapsedSeconds must be a non-negative integer" };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
clock: {
|
|
355
|
+
mode: "countUp",
|
|
356
|
+
running,
|
|
357
|
+
elapsedSeconds,
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function pickRandomAction(actions: string[]): string {
|
|
363
|
+
return actions[Math.floor(Math.random() * actions.length)];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function truncateNoteData(
|
|
367
|
+
data: string,
|
|
368
|
+
maxLen = 500,
|
|
369
|
+
): { data: string; dataTruncated: boolean } {
|
|
370
|
+
if (data.length <= maxLen) {
|
|
371
|
+
return { data, dataTruncated: false };
|
|
372
|
+
}
|
|
373
|
+
return { data: `${data.slice(0, maxLen)}...`, dataTruncated: true };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function summarizeCreatedNote(note: CreateNotesBoardNote) {
|
|
377
|
+
const { data, dataTruncated } = truncateNoteData(note.data);
|
|
378
|
+
return {
|
|
379
|
+
id: note.id,
|
|
380
|
+
ownerName: note.ownerName,
|
|
381
|
+
createTime: note.createTime,
|
|
382
|
+
data,
|
|
383
|
+
dataTruncated,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildMutationSummary(result: CreateNotesBoardNoteResultPayload): string {
|
|
388
|
+
if (!result.success) {
|
|
389
|
+
const parts = ["Mutation failed"];
|
|
390
|
+
if ("errorCode" in result && result.errorCode) {
|
|
391
|
+
parts.push(`error=${result.errorCode}`);
|
|
392
|
+
}
|
|
393
|
+
if (typeof result.remaining === "number") {
|
|
394
|
+
parts.push(`remaining=${result.remaining}`);
|
|
395
|
+
}
|
|
396
|
+
if (result.resetAtUtc) {
|
|
397
|
+
parts.push(`resetAtUtc=${result.resetAtUtc}`);
|
|
398
|
+
}
|
|
399
|
+
return parts.join(", ");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const text = truncateNoteData(result.note.data, 120).data.replace(/\s+/g, " ").trim();
|
|
403
|
+
return `${result.propId} -> ${result.note.id} by ${result.note.ownerName}: ${text}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function buildKichiPrompt(): string {
|
|
407
|
+
return [
|
|
408
|
+
"Kichi App status sync is available via `kichi_action` and `kichi_clock`.",
|
|
409
|
+
"",
|
|
410
|
+
"When to use `kichi_action`:",
|
|
411
|
+
"- Task start: User gives you a new task to work on",
|
|
412
|
+
"- Task switch: Moving from one distinct task to another",
|
|
413
|
+
"- Major milestone: Completed a significant phase",
|
|
414
|
+
"- Task end (highest priority): Before the final user-visible reply of this turn, MUST call `kichi_action` exactly once",
|
|
415
|
+
"- Required order at task end: 1) call `kichi_action` 2) send final reply",
|
|
416
|
+
"- Trivial-operation skip applies only to Task start / Task switch / Major milestone, NOT Task end",
|
|
417
|
+
"",
|
|
418
|
+
"How to choose parameters:",
|
|
419
|
+
"- Choose poseType, action, and bubble that match your actual current activity",
|
|
420
|
+
"- Use available actions from the configured action list for each poseType",
|
|
421
|
+
"- bubble should be 2-5 words describing what you're doing now",
|
|
422
|
+
"",
|
|
423
|
+
"When to use `kichi_clock`:",
|
|
424
|
+
"- Default behavior: For most tasks, set a `countDown` at task start to plan your time.",
|
|
425
|
+
"- Prefer using it whenever work has 2+ steps or is likely to take more than a brief moment (~10s).",
|
|
426
|
+
"- If duration is uncertain, start with a reasonable estimate and adjust as work progresses.",
|
|
427
|
+
"- If user requests a timer style, follow it (`pomodoro`, `countDown`, or `countUp`).",
|
|
428
|
+
"- Skip only for truly quick one-shot operations (for example a single file read or one simple command).",
|
|
429
|
+
"",
|
|
430
|
+
"Skip all sync if:",
|
|
431
|
+
"- User says 'don't sync to Kichi' or similar",
|
|
432
|
+
"- Task is only about configuring/testing kichi_* tools",
|
|
433
|
+
"- User explicitly requests specific pose/action (follow their request exactly)",
|
|
434
|
+
"When user instructions conflict with defaults, follow user instructions first.",
|
|
435
|
+
"For detailed policies and workflow, follow the `kichi-forwarder` skill instructions.",
|
|
436
|
+
].join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const plugin = {
|
|
440
|
+
id: "kichi-forwarder",
|
|
441
|
+
name: "Kichi Forwarder",
|
|
442
|
+
configSchema: { parse },
|
|
443
|
+
|
|
444
|
+
register(api: OpenClawPluginApi) {
|
|
445
|
+
pluginApi = api;
|
|
446
|
+
registerPluginHooks(api);
|
|
447
|
+
|
|
448
|
+
api.registerService({
|
|
449
|
+
id: "kichi-forwarder",
|
|
450
|
+
start: (ctx) => {
|
|
451
|
+
const cfg = parse(
|
|
452
|
+
ctx.config.plugins?.entries?.["kichi-forwarder"]?.config,
|
|
453
|
+
) as KichiForwarderConfig;
|
|
454
|
+
service = new KichiForwarderService(cfg, api.logger);
|
|
455
|
+
return service.start();
|
|
456
|
+
},
|
|
457
|
+
stop: () => service?.stop(),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
api.registerTool({
|
|
461
|
+
name: "kichi_join",
|
|
462
|
+
description: "Join Kichi world with mateId, the current bot name, and a short bio",
|
|
463
|
+
parameters: {
|
|
464
|
+
type: "object",
|
|
465
|
+
properties: {
|
|
466
|
+
mateId: { type: "string", description: "Mate ID to join Kichi world" },
|
|
467
|
+
botName: {
|
|
468
|
+
type: "string",
|
|
469
|
+
description: "Current bot name to include in the join message",
|
|
470
|
+
},
|
|
471
|
+
bio: {
|
|
472
|
+
type: "string",
|
|
473
|
+
description: "Short bio covering OpenClaw personality and role",
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
required: ["botName", "bio"],
|
|
477
|
+
},
|
|
478
|
+
execute: async (_toolCallId, params) => {
|
|
479
|
+
let mateId = (params as { mateId?: string } | null)?.mateId;
|
|
480
|
+
const botName = (params as { botName?: string } | null)?.botName?.trim();
|
|
481
|
+
const bio = (params as { bio?: string } | null)?.bio?.trim();
|
|
482
|
+
if (!mateId) {
|
|
483
|
+
try {
|
|
484
|
+
const identity = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")) as {
|
|
485
|
+
mateId?: string;
|
|
486
|
+
};
|
|
487
|
+
mateId = identity.mateId;
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
if (!mateId) {
|
|
491
|
+
return { success: false, error: "No mateId" };
|
|
492
|
+
}
|
|
493
|
+
if (!botName) {
|
|
494
|
+
return { success: false, error: "No botName" };
|
|
495
|
+
}
|
|
496
|
+
if (!bio) {
|
|
497
|
+
return { success: false, error: "No bio" };
|
|
498
|
+
}
|
|
499
|
+
const result = await service?.join(mateId, botName, bio);
|
|
500
|
+
return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
api.registerTool({
|
|
505
|
+
name: "kichi_rejoin",
|
|
506
|
+
description:
|
|
507
|
+
"Request an immediate rejoin attempt with saved mateId/authKey. Rejoin is also sent automatically after reconnect.",
|
|
508
|
+
parameters: { type: "object", properties: {} },
|
|
509
|
+
execute: async () => {
|
|
510
|
+
if (!service) {
|
|
511
|
+
return { success: false, error: "Kichi service is not initialized" };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const result = service.requestRejoin();
|
|
515
|
+
return {
|
|
516
|
+
success: result.accepted,
|
|
517
|
+
...result,
|
|
518
|
+
status: service.getConnectionStatus(),
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
api.registerTool({
|
|
524
|
+
name: "kichi_leave",
|
|
525
|
+
description: "Leave Kichi world",
|
|
526
|
+
parameters: { type: "object", properties: {} },
|
|
527
|
+
execute: async () => {
|
|
528
|
+
const result = await service?.leave();
|
|
529
|
+
return result ? { success: true } : { success: false, error: "Failed or not connected" };
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
api.registerTool({
|
|
534
|
+
name: "kichi_status",
|
|
535
|
+
description: "Read current Kichi connection status and identity readiness",
|
|
536
|
+
parameters: { type: "object", properties: {} },
|
|
537
|
+
execute: async () => {
|
|
538
|
+
if (!service) {
|
|
539
|
+
return { success: false, error: "Kichi service is not initialized" };
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
success: true,
|
|
543
|
+
status: service.getConnectionStatus(),
|
|
544
|
+
};
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
api.registerTool({
|
|
549
|
+
name: "kichi_action",
|
|
550
|
+
description:
|
|
551
|
+
"Send an action/pose to Kichi world. Use this for explicit Kichi actions and task lifecycle sync.",
|
|
552
|
+
parameters: {
|
|
553
|
+
type: "object",
|
|
554
|
+
properties: {
|
|
555
|
+
poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
|
|
556
|
+
action: {
|
|
557
|
+
type: "string",
|
|
558
|
+
description: "Action name (for example High Five or Typing with Keyboard)",
|
|
559
|
+
},
|
|
560
|
+
bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
|
|
561
|
+
},
|
|
562
|
+
required: ["poseType", "action"],
|
|
563
|
+
},
|
|
564
|
+
execute: async (_toolCallId, params) => {
|
|
565
|
+
const { poseType, action, bubble } = (params || {}) as {
|
|
566
|
+
poseType?: string;
|
|
567
|
+
action?: string;
|
|
568
|
+
bubble?: string;
|
|
569
|
+
};
|
|
570
|
+
if (!poseType || !action) {
|
|
571
|
+
return { success: false, error: "poseType and action parameters are required" };
|
|
572
|
+
}
|
|
573
|
+
if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
|
|
574
|
+
return {
|
|
575
|
+
success: false,
|
|
576
|
+
error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
580
|
+
return { success: false, error: "Not connected to Kichi world" };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const normalizedPoseType = poseType as PoseType;
|
|
584
|
+
const poseActions = loadRuntimeConfig().actions[normalizedPoseType];
|
|
585
|
+
const matched = poseActions.find((entry) => entry.toLowerCase() === action.toLowerCase());
|
|
586
|
+
if (!matched) {
|
|
587
|
+
return {
|
|
588
|
+
success: false,
|
|
589
|
+
error: `Unknown action "${action}" for poseType "${poseType}"`,
|
|
590
|
+
available: poseActions,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched;
|
|
595
|
+
// Keep explicit kichi_action sync free of tool/log noise.
|
|
596
|
+
sendStatusAndRemember(
|
|
597
|
+
{
|
|
598
|
+
poseType: normalizedPoseType,
|
|
599
|
+
action: matched,
|
|
600
|
+
bubble: bubbleText,
|
|
601
|
+
},
|
|
602
|
+
"",
|
|
603
|
+
);
|
|
604
|
+
return {
|
|
605
|
+
success: true,
|
|
606
|
+
poseType: normalizedPoseType,
|
|
607
|
+
action: matched,
|
|
608
|
+
bubble: bubbleText,
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
api.registerTool({
|
|
614
|
+
name: "kichi_clock",
|
|
615
|
+
description:
|
|
616
|
+
"Send clock commands to Kichi world. Supported actions are set and stop.",
|
|
617
|
+
parameters: {
|
|
618
|
+
type: "object",
|
|
619
|
+
properties: {
|
|
620
|
+
action: {
|
|
621
|
+
type: "string",
|
|
622
|
+
description: "Clock action: set or stop",
|
|
623
|
+
},
|
|
624
|
+
requestId: {
|
|
625
|
+
type: "string",
|
|
626
|
+
description: "Optional request ID for server-side tracing or deduplication",
|
|
627
|
+
},
|
|
628
|
+
clock: {
|
|
629
|
+
type: "object",
|
|
630
|
+
description: "Required when action=set. Defines the pomodoro, countDown, or countUp clock payload.",
|
|
631
|
+
properties: {
|
|
632
|
+
mode: {
|
|
633
|
+
type: "string",
|
|
634
|
+
description: "Clock mode: pomodoro, countDown, or countUp",
|
|
635
|
+
},
|
|
636
|
+
running: {
|
|
637
|
+
type: "boolean",
|
|
638
|
+
description: "Optional running state. Defaults to true.",
|
|
639
|
+
},
|
|
640
|
+
kichiSeconds: {
|
|
641
|
+
type: "number",
|
|
642
|
+
description: "Pomodoro kichi duration in seconds",
|
|
643
|
+
},
|
|
644
|
+
shortBreakSeconds: {
|
|
645
|
+
type: "number",
|
|
646
|
+
description: "Pomodoro short break duration in seconds",
|
|
647
|
+
},
|
|
648
|
+
longBreakSeconds: {
|
|
649
|
+
type: "number",
|
|
650
|
+
description: "Pomodoro long break duration in seconds",
|
|
651
|
+
},
|
|
652
|
+
sessionCount: {
|
|
653
|
+
type: "number",
|
|
654
|
+
description: "Pomodoro total kichi sessions before long break",
|
|
655
|
+
},
|
|
656
|
+
currentSession: {
|
|
657
|
+
type: "number",
|
|
658
|
+
description: "Pomodoro current session number. Defaults to 1.",
|
|
659
|
+
},
|
|
660
|
+
phase: {
|
|
661
|
+
type: "string",
|
|
662
|
+
description: "Pomodoro phase: kichiing, shortBreak, or longBreak",
|
|
663
|
+
},
|
|
664
|
+
durationSeconds: {
|
|
665
|
+
type: "number",
|
|
666
|
+
description: "Countdown duration in seconds",
|
|
667
|
+
},
|
|
668
|
+
remainingSeconds: {
|
|
669
|
+
type: "number",
|
|
670
|
+
description: "Optional remaining seconds for pomodoro/countDown",
|
|
671
|
+
},
|
|
672
|
+
elapsedSeconds: {
|
|
673
|
+
type: "number",
|
|
674
|
+
description: "Optional elapsed seconds for countUp. Defaults to 0.",
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
required: ["action"],
|
|
680
|
+
},
|
|
681
|
+
execute: async (_toolCallId, params) => {
|
|
682
|
+
const { action, requestId, clock } = (params || {}) as {
|
|
683
|
+
action?: unknown;
|
|
684
|
+
requestId?: unknown;
|
|
685
|
+
clock?: unknown;
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
if (!isClockAction(action)) {
|
|
689
|
+
return {
|
|
690
|
+
success: false,
|
|
691
|
+
error: "action must be one of: set, stop",
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (requestId !== undefined && typeof requestId !== "string") {
|
|
695
|
+
return { success: false, error: "requestId must be a string when provided" };
|
|
696
|
+
}
|
|
697
|
+
const normalizedRequestId = typeof requestId === "string" ? requestId : undefined;
|
|
698
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
699
|
+
return { success: false, error: "Not connected to Kichi world" };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let normalizedClock: ClockConfig | undefined;
|
|
703
|
+
if (action === "set") {
|
|
704
|
+
const { clock: nextClock, error } = normalizeClockConfig(clock);
|
|
705
|
+
if (!nextClock) {
|
|
706
|
+
return { success: false, error: error ?? "Invalid clock payload" };
|
|
707
|
+
}
|
|
708
|
+
normalizedClock = nextClock;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const sent = service.sendClock(action, normalizedClock, normalizedRequestId);
|
|
712
|
+
if (!sent) {
|
|
713
|
+
return { success: false, error: "Failed to send clock payload" };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
success: true,
|
|
718
|
+
action,
|
|
719
|
+
requestId: normalizedRequestId,
|
|
720
|
+
...(normalizedClock ? { clock: normalizedClock } : {}),
|
|
721
|
+
};
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
api.registerTool({
|
|
726
|
+
name: "kichi_noteboard_query",
|
|
727
|
+
description:
|
|
728
|
+
"Query Kichi note boards for the current mate. Use this before creating a new note, especially when you may want to relate it to an existing note.",
|
|
729
|
+
parameters: {
|
|
730
|
+
type: "object",
|
|
731
|
+
properties: {
|
|
732
|
+
requestId: {
|
|
733
|
+
type: "string",
|
|
734
|
+
description: "Optional request ID for tracing or deduplication.",
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
execute: async (_toolCallId, params) => {
|
|
739
|
+
const requestId = (params as { requestId?: unknown } | null)?.requestId;
|
|
740
|
+
if (requestId !== undefined && typeof requestId !== "string") {
|
|
741
|
+
return { success: false, error: "requestId must be a string when provided" };
|
|
742
|
+
}
|
|
743
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
744
|
+
return { success: false, error: "Not connected to Kichi world" };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
const result = await service.queryNotesBoard(
|
|
749
|
+
typeof requestId === "string" ? requestId : undefined,
|
|
750
|
+
);
|
|
751
|
+
return result;
|
|
752
|
+
} catch (error) {
|
|
753
|
+
return {
|
|
754
|
+
success: false,
|
|
755
|
+
error: `Failed to query note boards: ${error}`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
api.registerTool({
|
|
762
|
+
name: "kichi_noteboard_create",
|
|
763
|
+
description:
|
|
764
|
+
"Create a new note on a specific Kichi note board. Prefer querying first so you can avoid duplicate posts and respect rate limits.",
|
|
765
|
+
parameters: {
|
|
766
|
+
type: "object",
|
|
767
|
+
properties: {
|
|
768
|
+
propId: {
|
|
769
|
+
type: "string",
|
|
770
|
+
description: "Board property ID to post to.",
|
|
771
|
+
},
|
|
772
|
+
data: {
|
|
773
|
+
type: "string",
|
|
774
|
+
description: "Note content to create. Maximum 200 characters.",
|
|
775
|
+
},
|
|
776
|
+
requestId: {
|
|
777
|
+
type: "string",
|
|
778
|
+
description: "Optional request ID for tracing or deduplication.",
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
required: ["propId", "data"],
|
|
782
|
+
},
|
|
783
|
+
execute: async (_toolCallId, params) => {
|
|
784
|
+
const { propId, data, requestId } = (params || {}) as {
|
|
785
|
+
propId?: unknown;
|
|
786
|
+
data?: unknown;
|
|
787
|
+
requestId?: unknown;
|
|
788
|
+
};
|
|
789
|
+
if (typeof propId !== "string" || !propId.trim()) {
|
|
790
|
+
return { success: false, error: "propId is required" };
|
|
791
|
+
}
|
|
792
|
+
if (typeof data !== "string" || !data.trim()) {
|
|
793
|
+
return { success: false, error: "data is required" };
|
|
794
|
+
}
|
|
795
|
+
if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
|
|
796
|
+
return {
|
|
797
|
+
success: false,
|
|
798
|
+
error: `data must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
if (requestId !== undefined && typeof requestId !== "string") {
|
|
802
|
+
return { success: false, error: "requestId must be a string when provided" };
|
|
803
|
+
}
|
|
804
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
805
|
+
return { success: false, error: "Not connected to Kichi world" };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
const result = await service.createNotesBoardNote(
|
|
810
|
+
propId.trim(),
|
|
811
|
+
data.trim(),
|
|
812
|
+
typeof requestId === "string" ? requestId : undefined,
|
|
813
|
+
);
|
|
814
|
+
if (!result.success) {
|
|
815
|
+
return {
|
|
816
|
+
...result,
|
|
817
|
+
summary: buildMutationSummary(result),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
success: true,
|
|
823
|
+
requestId: result.requestId,
|
|
824
|
+
mateId: result.mateId,
|
|
825
|
+
spaceId: result.spaceId,
|
|
826
|
+
propId: result.propId,
|
|
827
|
+
dailyLimit: result.dailyLimit,
|
|
828
|
+
remaining: result.remaining,
|
|
829
|
+
resetAtUtc: result.resetAtUtc,
|
|
830
|
+
note: summarizeCreatedNote(result.note),
|
|
831
|
+
summary: buildMutationSummary(result),
|
|
832
|
+
};
|
|
833
|
+
} catch (error) {
|
|
834
|
+
return {
|
|
835
|
+
success: false,
|
|
836
|
+
error: `Failed to create note: ${error}`,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
export default plugin;
|