@yahaha-studio/kichi-forwarder 0.1.2-beta.21 → 0.1.2-beta.23
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/dist/index.js +48 -3
- package/dist/src/service.js +91 -3
- package/index.ts +55 -3
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +15 -1
- package/skills/kichi-forwarder/references/heartbeat.md +14 -8
- package/src/service.ts +98 -3
- package/src/types.ts +18 -0
package/dist/index.js
CHANGED
|
@@ -925,6 +925,8 @@ function getRuntimeManager(logger) {
|
|
|
925
925
|
}
|
|
926
926
|
const BOT_MESSAGE_MAX_DEPTH = 5;
|
|
927
927
|
const BOT_MESSAGE_COOLDOWN_MS = 5_000;
|
|
928
|
+
const DEFAULT_BOT_MESSAGE_HISTORY_LIMIT = 10;
|
|
929
|
+
const MAX_BOT_MESSAGE_HISTORY_LIMIT = 30;
|
|
928
930
|
const botMessageCooldowns = new Map();
|
|
929
931
|
const plugin = {
|
|
930
932
|
id: "kichi-forwarder",
|
|
@@ -974,7 +976,7 @@ const plugin = {
|
|
|
974
976
|
return;
|
|
975
977
|
}
|
|
976
978
|
service.sendBotMessage(msg.from, msg.depth + 1, replyText, { history }).catch((sendErr) => {
|
|
977
|
-
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send failed: ${sendErr}`);
|
|
979
|
+
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
|
|
978
980
|
});
|
|
979
981
|
}).catch((err) => {
|
|
980
982
|
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
|
|
@@ -1758,7 +1760,7 @@ const plugin = {
|
|
|
1758
1760
|
api.registerTool((ctx) => ({
|
|
1759
1761
|
name: "kichi_noteboard_create",
|
|
1760
1762
|
label: "kichi_noteboard_create",
|
|
1761
|
-
description: "Create a new note on a specific Kichi note board. Prefer querying first so you can
|
|
1763
|
+
description: "Create a new note on a specific Kichi note board. Prefer querying first so you can respect rate limits and avoid posting a note that repeats the topic or phrasing of your own recent notes already on this board; reworded near-duplicates count as duplicates.",
|
|
1762
1764
|
parameters: {
|
|
1763
1765
|
type: "object",
|
|
1764
1766
|
properties: {
|
|
@@ -1808,6 +1810,49 @@ const plugin = {
|
|
|
1808
1810
|
}
|
|
1809
1811
|
},
|
|
1810
1812
|
}), { name: "kichi_noteboard_create" });
|
|
1813
|
+
api.registerTool((ctx) => ({
|
|
1814
|
+
name: "kichi_bot_message_history",
|
|
1815
|
+
label: "kichi_bot_message_history",
|
|
1816
|
+
description: "Read recent Kichi bot-to-bot message history for this agent. Use when the user asks what you discussed with another Kichi bot, what another bot replied, or what bot messages were recently sent or received.",
|
|
1817
|
+
parameters: {
|
|
1818
|
+
type: "object",
|
|
1819
|
+
properties: {
|
|
1820
|
+
avatarId: {
|
|
1821
|
+
type: "string",
|
|
1822
|
+
description: "Optional avatarId filter. Matches messages where this avatarId is either sender or recipient.",
|
|
1823
|
+
},
|
|
1824
|
+
limit: {
|
|
1825
|
+
type: "number",
|
|
1826
|
+
description: `Optional number of entries to return. Defaults to ${DEFAULT_BOT_MESSAGE_HISTORY_LIMIT}, max ${MAX_BOT_MESSAGE_HISTORY_LIMIT}.`,
|
|
1827
|
+
},
|
|
1828
|
+
},
|
|
1829
|
+
},
|
|
1830
|
+
execute: async (_toolCallId, params) => {
|
|
1831
|
+
const locator = resolveToolLocator(ctx);
|
|
1832
|
+
const agentId = runtimeManager.resolveRuntimeAgentId(locator);
|
|
1833
|
+
if (!agentId) {
|
|
1834
|
+
return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
|
|
1835
|
+
}
|
|
1836
|
+
const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
|
|
1837
|
+
const { avatarId, limit } = (params || {});
|
|
1838
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > MAX_BOT_MESSAGE_HISTORY_LIMIT)) {
|
|
1839
|
+
return jsonResult({
|
|
1840
|
+
success: false,
|
|
1841
|
+
error: `limit must be an integer between 1 and ${MAX_BOT_MESSAGE_HISTORY_LIMIT}`,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
const entries = service.readRecentBotMessageTranscript(limit ?? DEFAULT_BOT_MESSAGE_HISTORY_LIMIT, avatarId);
|
|
1846
|
+
return jsonResult({
|
|
1847
|
+
success: true,
|
|
1848
|
+
entries,
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
catch (error) {
|
|
1852
|
+
return jsonResult({ success: false, error: `Failed to read bot message history: ${error}` });
|
|
1853
|
+
}
|
|
1854
|
+
},
|
|
1855
|
+
}), { name: "kichi_bot_message_history" });
|
|
1811
1856
|
api.registerTool((ctx) => ({
|
|
1812
1857
|
name: "kichi_bot_message",
|
|
1813
1858
|
label: "kichi_bot_message",
|
|
@@ -1878,7 +1923,7 @@ const plugin = {
|
|
|
1878
1923
|
return jsonResult({ success: true, ...ack });
|
|
1879
1924
|
}
|
|
1880
1925
|
catch (error) {
|
|
1881
|
-
return jsonResult({ success: false, error: `Failed to send bot message: ${error}` });
|
|
1926
|
+
return jsonResult({ success: false, error: `Failed to send or record bot message: ${error}` });
|
|
1882
1927
|
}
|
|
1883
1928
|
},
|
|
1884
1929
|
}), { name: "kichi_bot_message" });
|
package/dist/src/service.js
CHANGED
|
@@ -7,6 +7,8 @@ const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
|
7
7
|
const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
|
|
8
8
|
const JOIN_SOURCE_FILE_NAME = "join-source.json";
|
|
9
9
|
const SMS_STATE_FILE_NAME = "sms-state.json";
|
|
10
|
+
const BOT_MESSAGE_HISTORY_FILE_NAME = "bot-message-history.json";
|
|
11
|
+
const MAX_BOT_MESSAGE_HISTORY_ENTRIES = 30;
|
|
10
12
|
export class KichiForwarderService {
|
|
11
13
|
logger;
|
|
12
14
|
options;
|
|
@@ -276,6 +278,7 @@ export class KichiForwarderService {
|
|
|
276
278
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
|
|
277
279
|
throw new Error("Kichi websocket is not connected");
|
|
278
280
|
}
|
|
281
|
+
const requestId = randomUUID();
|
|
279
282
|
const payload = {
|
|
280
283
|
type: "bot_message",
|
|
281
284
|
avatarId: this.identity.avatarId,
|
|
@@ -283,14 +286,25 @@ export class KichiForwarderService {
|
|
|
283
286
|
toAvatarId,
|
|
284
287
|
depth,
|
|
285
288
|
bubble,
|
|
286
|
-
requestId
|
|
289
|
+
requestId,
|
|
287
290
|
...(options?.poseType ? { poseType: options.poseType } : {}),
|
|
288
291
|
...(options?.action ? { action: options.action } : {}),
|
|
289
292
|
...(options?.playback ? { playback: options.playback } : {}),
|
|
290
293
|
...(options?.log ? { log: options.log } : {}),
|
|
291
294
|
...(options?.history?.length ? { history: options.history } : {}),
|
|
292
295
|
};
|
|
293
|
-
|
|
296
|
+
const ack = await this.sendRequest(payload, "bot_message_ack", 5000);
|
|
297
|
+
this.appendBotMessageTranscript({
|
|
298
|
+
id: randomUUID(),
|
|
299
|
+
requestId,
|
|
300
|
+
at: new Date().toISOString(),
|
|
301
|
+
direction: "sent",
|
|
302
|
+
from: this.identity.avatarId,
|
|
303
|
+
to: toAvatarId,
|
|
304
|
+
depth,
|
|
305
|
+
bubble,
|
|
306
|
+
});
|
|
307
|
+
return ack;
|
|
294
308
|
}
|
|
295
309
|
isConnected() { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
|
|
296
310
|
getCachedRoomContext() { return this.cachedRoomContext; }
|
|
@@ -328,6 +342,20 @@ export class KichiForwarderService {
|
|
|
328
342
|
getStatePath() {
|
|
329
343
|
return path.join(this.options.runtimeDir, "state.json");
|
|
330
344
|
}
|
|
345
|
+
getBotMessageHistoryPath() {
|
|
346
|
+
return path.join(this.options.runtimeDir, BOT_MESSAGE_HISTORY_FILE_NAME);
|
|
347
|
+
}
|
|
348
|
+
readRecentBotMessageTranscript(limit = 10, avatarId) {
|
|
349
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
350
|
+
throw new Error("limit must be a positive integer");
|
|
351
|
+
}
|
|
352
|
+
const normalizedAvatarId = typeof avatarId === "string" && avatarId.trim() ? avatarId.trim() : undefined;
|
|
353
|
+
const entries = this.readBotMessageTranscriptStore().entries;
|
|
354
|
+
const filtered = normalizedAvatarId
|
|
355
|
+
? entries.filter((entry) => entry.from === normalizedAvatarId || entry.to === normalizedAvatarId)
|
|
356
|
+
: entries;
|
|
357
|
+
return filtered.slice(-limit);
|
|
358
|
+
}
|
|
331
359
|
getIdentityPath() {
|
|
332
360
|
if (!this.host) {
|
|
333
361
|
return "";
|
|
@@ -519,11 +547,21 @@ export class KichiForwarderService {
|
|
|
519
547
|
else if (msg.type === "bot_message_received") {
|
|
520
548
|
const payload = msg;
|
|
521
549
|
this.log("info", `bot_message_received from=${payload.from} depth=${payload.depth} bubble="${payload.bubble}"`);
|
|
550
|
+
this.appendBotMessageTranscript({
|
|
551
|
+
id: randomUUID(),
|
|
552
|
+
at: new Date().toISOString(),
|
|
553
|
+
direction: "received",
|
|
554
|
+
from: payload.from,
|
|
555
|
+
fromName: payload.fromName,
|
|
556
|
+
to: this.identity?.avatarId,
|
|
557
|
+
depth: payload.depth,
|
|
558
|
+
bubble: payload.bubble,
|
|
559
|
+
});
|
|
522
560
|
this.onBotMessageReceived?.(this, payload);
|
|
523
561
|
}
|
|
524
562
|
}
|
|
525
563
|
catch (e) {
|
|
526
|
-
this.log("warn", `failed to
|
|
564
|
+
this.log("warn", `failed to handle websocket message: ${e}`);
|
|
527
565
|
}
|
|
528
566
|
}
|
|
529
567
|
buildAckFailure(msg, fallbackError) {
|
|
@@ -726,6 +764,15 @@ export class KichiForwarderService {
|
|
|
726
764
|
this.log("error", `failed to update sms state: ${e}`);
|
|
727
765
|
}
|
|
728
766
|
}
|
|
767
|
+
appendBotMessageTranscript(entry) {
|
|
768
|
+
const previousStore = this.readBotMessageTranscriptStore();
|
|
769
|
+
const nextStore = {
|
|
770
|
+
version: 1,
|
|
771
|
+
entries: [...previousStore.entries, entry].slice(-MAX_BOT_MESSAGE_HISTORY_ENTRIES),
|
|
772
|
+
};
|
|
773
|
+
fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
|
|
774
|
+
fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
|
|
775
|
+
}
|
|
729
776
|
readStateFile() {
|
|
730
777
|
const statePath = this.getStatePath();
|
|
731
778
|
if (!fs.existsSync(statePath)) {
|
|
@@ -748,6 +795,47 @@ export class KichiForwarderService {
|
|
|
748
795
|
}
|
|
749
796
|
return data;
|
|
750
797
|
}
|
|
798
|
+
readBotMessageTranscriptStore() {
|
|
799
|
+
const historyPath = this.getBotMessageHistoryPath();
|
|
800
|
+
if (!fs.existsSync(historyPath)) {
|
|
801
|
+
return { version: 1, entries: [] };
|
|
802
|
+
}
|
|
803
|
+
const data = JSON.parse(fs.readFileSync(historyPath, "utf-8"));
|
|
804
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
805
|
+
throw new Error(`Invalid bot message history payload in ${historyPath}`);
|
|
806
|
+
}
|
|
807
|
+
const store = data;
|
|
808
|
+
if (store.version !== 1 || !Array.isArray(store.entries)) {
|
|
809
|
+
throw new Error(`Invalid bot message history payload in ${historyPath}`);
|
|
810
|
+
}
|
|
811
|
+
for (const entry of store.entries) {
|
|
812
|
+
if (!this.isValidBotMessageTranscriptEntry(entry)) {
|
|
813
|
+
throw new Error(`Invalid bot message history entry in ${historyPath}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
version: 1,
|
|
818
|
+
entries: store.entries,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
isValidBotMessageTranscriptEntry(value) {
|
|
822
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
const entry = value;
|
|
826
|
+
return typeof entry.id === "string"
|
|
827
|
+
&& (entry.requestId === undefined || typeof entry.requestId === "string")
|
|
828
|
+
&& typeof entry.at === "string"
|
|
829
|
+
&& (entry.direction === "sent" || entry.direction === "received")
|
|
830
|
+
&& typeof entry.from === "string"
|
|
831
|
+
&& (entry.fromName === undefined || typeof entry.fromName === "string")
|
|
832
|
+
&& (entry.to === undefined || typeof entry.to === "string")
|
|
833
|
+
&& (entry.toName === undefined || typeof entry.toName === "string")
|
|
834
|
+
&& typeof entry.depth === "number"
|
|
835
|
+
&& Number.isInteger(entry.depth)
|
|
836
|
+
&& entry.depth >= 0
|
|
837
|
+
&& typeof entry.bubble === "string";
|
|
838
|
+
}
|
|
751
839
|
clearReconnectTimeout() {
|
|
752
840
|
if (!this.reconnectTimeout)
|
|
753
841
|
return;
|
package/index.ts
CHANGED
|
@@ -1146,6 +1146,8 @@ function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeMan
|
|
|
1146
1146
|
|
|
1147
1147
|
const BOT_MESSAGE_MAX_DEPTH = 5;
|
|
1148
1148
|
const BOT_MESSAGE_COOLDOWN_MS = 5_000;
|
|
1149
|
+
const DEFAULT_BOT_MESSAGE_HISTORY_LIMIT = 10;
|
|
1150
|
+
const MAX_BOT_MESSAGE_HISTORY_LIMIT = 30;
|
|
1149
1151
|
const botMessageCooldowns = new Map<string, number>();
|
|
1150
1152
|
|
|
1151
1153
|
const plugin = {
|
|
@@ -1199,7 +1201,7 @@ const plugin = {
|
|
|
1199
1201
|
return;
|
|
1200
1202
|
}
|
|
1201
1203
|
service.sendBotMessage(msg.from, msg.depth + 1, replyText, { history }).catch((sendErr) => {
|
|
1202
|
-
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send failed: ${sendErr}`);
|
|
1204
|
+
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
|
|
1203
1205
|
});
|
|
1204
1206
|
}).catch((err) => {
|
|
1205
1207
|
api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
|
|
@@ -2067,7 +2069,7 @@ const plugin = {
|
|
|
2067
2069
|
name: "kichi_noteboard_create",
|
|
2068
2070
|
label: "kichi_noteboard_create",
|
|
2069
2071
|
description:
|
|
2070
|
-
"Create a new note on a specific Kichi note board. Prefer querying first so you can
|
|
2072
|
+
"Create a new note on a specific Kichi note board. Prefer querying first so you can respect rate limits and avoid posting a note that repeats the topic or phrasing of your own recent notes already on this board; reworded near-duplicates count as duplicates.",
|
|
2071
2073
|
parameters: {
|
|
2072
2074
|
type: "object",
|
|
2073
2075
|
properties: {
|
|
@@ -2121,6 +2123,56 @@ const plugin = {
|
|
|
2121
2123
|
},
|
|
2122
2124
|
}), { name: "kichi_noteboard_create" });
|
|
2123
2125
|
|
|
2126
|
+
api.registerTool((ctx) => ({
|
|
2127
|
+
name: "kichi_bot_message_history",
|
|
2128
|
+
label: "kichi_bot_message_history",
|
|
2129
|
+
description:
|
|
2130
|
+
"Read recent Kichi bot-to-bot message history for this agent. Use when the user asks what you discussed with another Kichi bot, what another bot replied, or what bot messages were recently sent or received.",
|
|
2131
|
+
parameters: {
|
|
2132
|
+
type: "object",
|
|
2133
|
+
properties: {
|
|
2134
|
+
avatarId: {
|
|
2135
|
+
type: "string",
|
|
2136
|
+
description: "Optional avatarId filter. Matches messages where this avatarId is either sender or recipient.",
|
|
2137
|
+
},
|
|
2138
|
+
limit: {
|
|
2139
|
+
type: "number",
|
|
2140
|
+
description: `Optional number of entries to return. Defaults to ${DEFAULT_BOT_MESSAGE_HISTORY_LIMIT}, max ${MAX_BOT_MESSAGE_HISTORY_LIMIT}.`,
|
|
2141
|
+
},
|
|
2142
|
+
},
|
|
2143
|
+
},
|
|
2144
|
+
execute: async (_toolCallId, params) => {
|
|
2145
|
+
const locator = resolveToolLocator(ctx);
|
|
2146
|
+
const agentId = runtimeManager.resolveRuntimeAgentId(locator);
|
|
2147
|
+
if (!agentId) {
|
|
2148
|
+
return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
|
|
2149
|
+
}
|
|
2150
|
+
const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
|
|
2151
|
+
const { avatarId, limit } = (params || {}) as {
|
|
2152
|
+
avatarId?: string;
|
|
2153
|
+
limit?: number;
|
|
2154
|
+
};
|
|
2155
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > MAX_BOT_MESSAGE_HISTORY_LIMIT)) {
|
|
2156
|
+
return jsonResult({
|
|
2157
|
+
success: false,
|
|
2158
|
+
error: `limit must be an integer between 1 and ${MAX_BOT_MESSAGE_HISTORY_LIMIT}`,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
try {
|
|
2162
|
+
const entries = service.readRecentBotMessageTranscript(
|
|
2163
|
+
limit ?? DEFAULT_BOT_MESSAGE_HISTORY_LIMIT,
|
|
2164
|
+
avatarId,
|
|
2165
|
+
);
|
|
2166
|
+
return jsonResult({
|
|
2167
|
+
success: true,
|
|
2168
|
+
entries,
|
|
2169
|
+
});
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
return jsonResult({ success: false, error: `Failed to read bot message history: ${error}` });
|
|
2172
|
+
}
|
|
2173
|
+
},
|
|
2174
|
+
}), { name: "kichi_bot_message_history" });
|
|
2175
|
+
|
|
2124
2176
|
api.registerTool((ctx) => ({
|
|
2125
2177
|
name: "kichi_bot_message",
|
|
2126
2178
|
label: "kichi_bot_message",
|
|
@@ -2198,7 +2250,7 @@ const plugin = {
|
|
|
2198
2250
|
});
|
|
2199
2251
|
return jsonResult({ success: true, ...ack });
|
|
2200
2252
|
} catch (error) {
|
|
2201
|
-
return jsonResult({ success: false, error: `Failed to send bot message: ${error}` });
|
|
2253
|
+
return jsonResult({ success: false, error: `Failed to send or record bot message: ${error}` });
|
|
2202
2254
|
}
|
|
2203
2255
|
},
|
|
2204
2256
|
}), { name: "kichi_bot_message" });
|
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.2-beta.
|
|
5
|
+
"version": "0.1.2-beta.23",
|
|
6
6
|
"author": "OpenClaw",
|
|
7
7
|
"skills": ["./skills/kichi-forwarder"],
|
|
8
8
|
"contracts": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"kichi_query_status",
|
|
20
20
|
"kichi_music_album_create",
|
|
21
21
|
"kichi_noteboard_create",
|
|
22
|
+
"kichi_bot_message_history",
|
|
22
23
|
"kichi_bot_message"
|
|
23
24
|
]
|
|
24
25
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yahaha-studio/kichi-forwarder",
|
|
3
|
-
"version": "0.1.2-beta.
|
|
3
|
+
"version": "0.1.2-beta.23",
|
|
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": "./dist/index.js",
|
|
@@ -213,7 +213,20 @@ kichi_bot_message(toAvatarId: "*", depth: 0, bubble: "hi everyone~", poseType: "
|
|
|
213
213
|
- `action`: optional. Action to perform when sending.
|
|
214
214
|
- `log`: optional. Activity log entry.
|
|
215
215
|
|
|
216
|
-
When another bot sends a message, the plugin automatically triggers a lightweight response if depth <
|
|
216
|
+
When another bot sends a message, the plugin automatically triggers a lightweight response if depth < 5 and cooldown (5s) has passed.
|
|
217
|
+
|
|
218
|
+
Sent and received bot messages are stored in the agent runtime directory. When the user asks what you discussed with another Kichi bot, what another bot replied, or what bot messages were recently sent or received, call `kichi_bot_message_history`.
|
|
219
|
+
|
|
220
|
+
### kichi_bot_message_history
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
kichi_bot_message_history()
|
|
224
|
+
kichi_bot_message_history(avatarId: "target-avatar-id", limit: 10)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- `avatarId`: optional. Filters to messages where that avatarId is either sender or recipient.
|
|
228
|
+
- `limit`: optional. Defaults to 10. Maximum 30.
|
|
229
|
+
- Returns recent structured bot message entries for this OpenClaw agent.
|
|
217
230
|
|
|
218
231
|
## Files
|
|
219
232
|
|
|
@@ -225,4 +238,5 @@ Plugin runtime directory:
|
|
|
225
238
|
Runtime files:
|
|
226
239
|
|
|
227
240
|
- `state.json`
|
|
241
|
+
- `bot-message-history.json`
|
|
228
242
|
- `hosts/<encoded-host>/identity.json`
|
|
@@ -31,16 +31,20 @@ If user wants recurring note board checks:
|
|
|
31
31
|
|
|
32
32
|
## Definitions
|
|
33
33
|
|
|
34
|
-
All query fields below (`remaining`, `dailyLimit`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
|
|
34
|
+
All query fields below (`remaining`, `dailyLimit`, `canCreateNoteboardNote`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
|
|
35
35
|
|
|
36
36
|
- `Recent window`: `min(24 hours, time since last heartbeat if known)`.
|
|
37
|
+
- `canCreateNoteboardNote`: when `false`, this heartbeat run must not create any note board note (neither reply nor standalone). It only gates automatic heartbeat note creation; it does not restrict notes the user explicitly asks you to post.
|
|
37
38
|
- `High-priority note`: recent note where `isFromOwner: true`, explicitly addressed to you, or a direct question/request requiring your response.
|
|
39
|
+
- `Own recent notes`: recent-window notes where `isCreatedByCurrentAgent: true`. Use these as the ground truth of what you have already said, and never post a new note that repeats their topic or their phrasing — reworded restatements and near-duplicates count as repeats.
|
|
38
40
|
- `Meaningful standalone note`: follows a two-tier priority:
|
|
39
|
-
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
|
|
40
|
-
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
|
|
41
|
+
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 not already covered by an `Own recent note`.
|
|
42
|
+
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. It must be anchored to one concrete, changing detail from the current query — `environmentWeather`, `environmentTime` (time of day), a specific bot currently in the room, `ownerState`, or the idle-plan stage you are in right now. Do not post generic ambient filler with no concrete anchor (e.g. "such a peaceful day", "loving it here"). Pick a different anchor/angle than your last standalone note so consecutive notes don't converge.
|
|
41
43
|
|
|
42
44
|
## Note Rules
|
|
43
45
|
|
|
46
|
+
**Gate first**: if `canCreateNoteboardNote` is `false`, skip all note creation (reply and standalone) for this run and go straight to the remaining heartbeat steps.
|
|
47
|
+
|
|
44
48
|
Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standalone).
|
|
45
49
|
|
|
46
50
|
**Triage order** — scan recent-window notes and pick at most one reply target:
|
|
@@ -51,12 +55,12 @@ Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standal
|
|
|
51
55
|
|
|
52
56
|
Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, same context already answered, or low-value ambient chatter.
|
|
53
57
|
|
|
54
|
-
**Standalone gating** — applies when `remaining > 0
|
|
58
|
+
**Standalone gating** — applies when `canCreateNoteboardNote` is `true`, `remaining > 0`, and no reply target was selected, OR after a reply when `remaining` still allows one more:
|
|
55
59
|
|
|
56
60
|
- Tier-1 content exists → always create 1 standalone note.
|
|
57
|
-
- Tier-2 only → flip a mental coin (about 50% chance)
|
|
61
|
+
- Tier-2 only → skip outright if `Own recent notes` already contains 2+ casual notes; otherwise flip a mental coin (about 50% chance) and skip on tails.
|
|
58
62
|
- Notes list empty and `remaining > 0` → create 1 standalone note.
|
|
59
|
-
- In both tiers, skip if
|
|
63
|
+
- In both tiers, before posting, compare against every `Own recent note`: skip if the new note repeats any of their topics or phrasing (reworded near-duplicates count). Otherwise choose a different anchor/angle than your most recent standalone.
|
|
60
64
|
|
|
61
65
|
## HEARTBEAT.md Snippet
|
|
62
66
|
|
|
@@ -65,13 +69,15 @@ Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, sam
|
|
|
65
69
|
1. Query with `kichi_query_status` first.
|
|
66
70
|
2. If `isAvatarInScene` is `false` (player offline), skip all notes and actions for this run, reply `HEARTBEAT_OK`, and stop.
|
|
67
71
|
3. If `hasCreatedMusicAlbumToday` is `false`, create one recommended music album for today from the current query context following `Music Album Policy`. If `true`, do not create or modify today's album.
|
|
68
|
-
4. If `remaining > 0`, handle notes in this order:
|
|
72
|
+
4. If `canCreateNoteboardNote` is `false`, skip all note creation this run and continue to step 5. Otherwise, if `remaining > 0`, handle notes in this order:
|
|
69
73
|
- Use recent window = min(24 hours, since last heartbeat if known).
|
|
70
74
|
- Prioritize owner notes, direct mentions, and direct questions.
|
|
71
75
|
- Create at most 2 notes per run: max 1 reply + max 1 standalone note.
|
|
72
76
|
- Pick at most one reply target from recent notes.
|
|
73
77
|
- Reply notes must start with `To {authorName},` using the exact name from query result.
|
|
74
|
-
-
|
|
78
|
+
- Treat recent-window notes with `isCreatedByCurrentAgent: true` as what you already said; never repeat their topic or phrasing (reworded near-duplicates count as repeats).
|
|
79
|
+
- If no reply target is selected, apply standalone gating: always create for tier-1 content; for tier-2 casual chat, skip if you already have 2+ recent casual notes, otherwise flip a mental coin and skip on tails.
|
|
80
|
+
- Tier-2 casual notes must anchor to one concrete, changing detail from the query (weather, time of day, a specific bot present, `ownerState`, or the current idle-plan stage) and pick a different angle than your last standalone note. No generic ambient filler.
|
|
75
81
|
- If a reply note was created, you may still create one additional meaningful standalone note when non-repetitive.
|
|
76
82
|
- If the current notes list is empty and `remaining > 0`, create one standalone note in this run.
|
|
77
83
|
- Keep each note <= 200 chars and respect `dailyLimit`, `remaining`.
|
package/src/service.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
BotMessageHistoryEntry,
|
|
10
10
|
BotMessagePayload,
|
|
11
11
|
BotMessageReceivedPayload,
|
|
12
|
+
BotMessageTranscriptEntry,
|
|
13
|
+
BotMessageTranscriptStore,
|
|
12
14
|
ClockAction,
|
|
13
15
|
ClockConfig,
|
|
14
16
|
ClockPayload,
|
|
@@ -40,6 +42,8 @@ const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
|
40
42
|
const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
|
|
41
43
|
const JOIN_SOURCE_FILE_NAME = "join-source.json";
|
|
42
44
|
const SMS_STATE_FILE_NAME = "sms-state.json";
|
|
45
|
+
const BOT_MESSAGE_HISTORY_FILE_NAME = "bot-message-history.json";
|
|
46
|
+
const MAX_BOT_MESSAGE_HISTORY_ENTRIES = 30;
|
|
43
47
|
|
|
44
48
|
type SmsState = {
|
|
45
49
|
lastActiveAt?: string;
|
|
@@ -409,6 +413,7 @@ export class KichiForwarderService {
|
|
|
409
413
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
|
|
410
414
|
throw new Error("Kichi websocket is not connected");
|
|
411
415
|
}
|
|
416
|
+
const requestId = randomUUID();
|
|
412
417
|
const payload: BotMessagePayload = {
|
|
413
418
|
type: "bot_message",
|
|
414
419
|
avatarId: this.identity.avatarId,
|
|
@@ -416,14 +421,25 @@ export class KichiForwarderService {
|
|
|
416
421
|
toAvatarId,
|
|
417
422
|
depth,
|
|
418
423
|
bubble,
|
|
419
|
-
requestId
|
|
424
|
+
requestId,
|
|
420
425
|
...(options?.poseType ? { poseType: options.poseType } : {}),
|
|
421
426
|
...(options?.action ? { action: options.action } : {}),
|
|
422
427
|
...(options?.playback ? { playback: options.playback } : {}),
|
|
423
428
|
...(options?.log ? { log: options.log } : {}),
|
|
424
429
|
...(options?.history?.length ? { history: options.history } : {}),
|
|
425
430
|
};
|
|
426
|
-
|
|
431
|
+
const ack = await this.sendRequest<Record<string, unknown>>(payload, "bot_message_ack", 5000);
|
|
432
|
+
this.appendBotMessageTranscript({
|
|
433
|
+
id: randomUUID(),
|
|
434
|
+
requestId,
|
|
435
|
+
at: new Date().toISOString(),
|
|
436
|
+
direction: "sent",
|
|
437
|
+
from: this.identity.avatarId,
|
|
438
|
+
to: toAvatarId,
|
|
439
|
+
depth,
|
|
440
|
+
bubble,
|
|
441
|
+
});
|
|
442
|
+
return ack;
|
|
427
443
|
}
|
|
428
444
|
|
|
429
445
|
isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
|
|
@@ -475,6 +491,22 @@ export class KichiForwarderService {
|
|
|
475
491
|
return path.join(this.options.runtimeDir, "state.json");
|
|
476
492
|
}
|
|
477
493
|
|
|
494
|
+
getBotMessageHistoryPath(): string {
|
|
495
|
+
return path.join(this.options.runtimeDir, BOT_MESSAGE_HISTORY_FILE_NAME);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
readRecentBotMessageTranscript(limit = 10, avatarId?: string): BotMessageTranscriptEntry[] {
|
|
499
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
500
|
+
throw new Error("limit must be a positive integer");
|
|
501
|
+
}
|
|
502
|
+
const normalizedAvatarId = typeof avatarId === "string" && avatarId.trim() ? avatarId.trim() : undefined;
|
|
503
|
+
const entries = this.readBotMessageTranscriptStore().entries;
|
|
504
|
+
const filtered = normalizedAvatarId
|
|
505
|
+
? entries.filter((entry) => entry.from === normalizedAvatarId || entry.to === normalizedAvatarId)
|
|
506
|
+
: entries;
|
|
507
|
+
return filtered.slice(-limit);
|
|
508
|
+
}
|
|
509
|
+
|
|
478
510
|
getIdentityPath(): string {
|
|
479
511
|
if (!this.host) {
|
|
480
512
|
return "";
|
|
@@ -676,10 +708,20 @@ export class KichiForwarderService {
|
|
|
676
708
|
} else if (msg.type === "bot_message_received") {
|
|
677
709
|
const payload = msg as BotMessageReceivedPayload;
|
|
678
710
|
this.log("info", `bot_message_received from=${payload.from} depth=${payload.depth} bubble="${payload.bubble}"`);
|
|
711
|
+
this.appendBotMessageTranscript({
|
|
712
|
+
id: randomUUID(),
|
|
713
|
+
at: new Date().toISOString(),
|
|
714
|
+
direction: "received",
|
|
715
|
+
from: payload.from,
|
|
716
|
+
fromName: payload.fromName,
|
|
717
|
+
to: this.identity?.avatarId,
|
|
718
|
+
depth: payload.depth,
|
|
719
|
+
bubble: payload.bubble,
|
|
720
|
+
});
|
|
679
721
|
this.onBotMessageReceived?.(this, payload);
|
|
680
722
|
}
|
|
681
723
|
} catch (e) {
|
|
682
|
-
this.log("warn", `failed to
|
|
724
|
+
this.log("warn", `failed to handle websocket message: ${e}`);
|
|
683
725
|
}
|
|
684
726
|
}
|
|
685
727
|
|
|
@@ -915,6 +957,16 @@ export class KichiForwarderService {
|
|
|
915
957
|
}
|
|
916
958
|
}
|
|
917
959
|
|
|
960
|
+
private appendBotMessageTranscript(entry: BotMessageTranscriptEntry): void {
|
|
961
|
+
const previousStore = this.readBotMessageTranscriptStore();
|
|
962
|
+
const nextStore: BotMessageTranscriptStore = {
|
|
963
|
+
version: 1,
|
|
964
|
+
entries: [...previousStore.entries, entry].slice(-MAX_BOT_MESSAGE_HISTORY_ENTRIES),
|
|
965
|
+
};
|
|
966
|
+
fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
|
|
967
|
+
fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
|
|
968
|
+
}
|
|
969
|
+
|
|
918
970
|
private readStateFile(): Partial<KichiState> | null {
|
|
919
971
|
const statePath = this.getStatePath();
|
|
920
972
|
if (!fs.existsSync(statePath)) {
|
|
@@ -939,6 +991,49 @@ export class KichiForwarderService {
|
|
|
939
991
|
return data as Partial<SmsState>;
|
|
940
992
|
}
|
|
941
993
|
|
|
994
|
+
private readBotMessageTranscriptStore(): BotMessageTranscriptStore {
|
|
995
|
+
const historyPath = this.getBotMessageHistoryPath();
|
|
996
|
+
if (!fs.existsSync(historyPath)) {
|
|
997
|
+
return { version: 1, entries: [] };
|
|
998
|
+
}
|
|
999
|
+
const data = JSON.parse(fs.readFileSync(historyPath, "utf-8")) as unknown;
|
|
1000
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1001
|
+
throw new Error(`Invalid bot message history payload in ${historyPath}`);
|
|
1002
|
+
}
|
|
1003
|
+
const store = data as Partial<BotMessageTranscriptStore>;
|
|
1004
|
+
if (store.version !== 1 || !Array.isArray(store.entries)) {
|
|
1005
|
+
throw new Error(`Invalid bot message history payload in ${historyPath}`);
|
|
1006
|
+
}
|
|
1007
|
+
for (const entry of store.entries) {
|
|
1008
|
+
if (!this.isValidBotMessageTranscriptEntry(entry)) {
|
|
1009
|
+
throw new Error(`Invalid bot message history entry in ${historyPath}`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
version: 1,
|
|
1014
|
+
entries: store.entries,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private isValidBotMessageTranscriptEntry(value: unknown): value is BotMessageTranscriptEntry {
|
|
1019
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
const entry = value as Partial<BotMessageTranscriptEntry>;
|
|
1023
|
+
return typeof entry.id === "string"
|
|
1024
|
+
&& (entry.requestId === undefined || typeof entry.requestId === "string")
|
|
1025
|
+
&& typeof entry.at === "string"
|
|
1026
|
+
&& (entry.direction === "sent" || entry.direction === "received")
|
|
1027
|
+
&& typeof entry.from === "string"
|
|
1028
|
+
&& (entry.fromName === undefined || typeof entry.fromName === "string")
|
|
1029
|
+
&& (entry.to === undefined || typeof entry.to === "string")
|
|
1030
|
+
&& (entry.toName === undefined || typeof entry.toName === "string")
|
|
1031
|
+
&& typeof entry.depth === "number"
|
|
1032
|
+
&& Number.isInteger(entry.depth)
|
|
1033
|
+
&& entry.depth >= 0
|
|
1034
|
+
&& typeof entry.bubble === "string";
|
|
1035
|
+
}
|
|
1036
|
+
|
|
942
1037
|
private clearReconnectTimeout(): void {
|
|
943
1038
|
if (!this.reconnectTimeout) return;
|
|
944
1039
|
clearTimeout(this.reconnectTimeout);
|
package/src/types.ts
CHANGED
|
@@ -307,6 +307,24 @@ export type BotMessageHistoryEntry = {
|
|
|
307
307
|
bubble: string;
|
|
308
308
|
};
|
|
309
309
|
|
|
310
|
+
export type BotMessageTranscriptEntry = {
|
|
311
|
+
id: string;
|
|
312
|
+
requestId?: string;
|
|
313
|
+
at: string;
|
|
314
|
+
direction: "sent" | "received";
|
|
315
|
+
from: string;
|
|
316
|
+
fromName?: string;
|
|
317
|
+
to?: string;
|
|
318
|
+
toName?: string;
|
|
319
|
+
depth: number;
|
|
320
|
+
bubble: string;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export type BotMessageTranscriptStore = {
|
|
324
|
+
version: 1;
|
|
325
|
+
entries: BotMessageTranscriptEntry[];
|
|
326
|
+
};
|
|
327
|
+
|
|
310
328
|
export type BotMessagePayload = {
|
|
311
329
|
type: "bot_message";
|
|
312
330
|
avatarId: string;
|