@coclaw/openclaw-coclaw 0.4.1 → 0.5.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/index.js CHANGED
@@ -6,10 +6,11 @@ import { registerCoclawCli } from './src/cli-registrar.js';
6
6
  import { resolveErrorMessage } from './src/common/errors.js';
7
7
  import { notBound, bindOk, unbindOk } from './src/common/messages.js';
8
8
  import { coclawChannelPlugin } from './src/channel-plugin.js';
9
- import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge } from './src/realtime-bridge.js';
9
+ import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady } from './src/realtime-bridge.js';
10
10
  import { setRuntime } from './src/runtime.js';
11
11
  import { createSessionManager } from './src/session-manager/manager.js';
12
12
  import { TopicManager } from './src/topic-manager/manager.js';
13
+ import { ChatHistoryManager } from './src/chat-history-manager/manager.js';
13
14
  import { generateTitle } from './src/topic-manager/title-gen.js';
14
15
  import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
15
16
  import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
@@ -78,13 +79,40 @@ const plugin = {
78
79
  const logger = api?.logger ?? console;
79
80
  const manager = createSessionManager({ logger });
80
81
  const topicManager = new TopicManager({ logger });
82
+ const chatHistoryManager = new ChatHistoryManager({ logger });
81
83
 
82
- // 懒加载 topic 数据(best-effort,不阻断注册)
84
+ // 懒加载 topic / chat history 数据(best-effort,不阻断注册)
83
85
  topicManager.load('main').catch((err) => {
84
86
  logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
85
87
  });
88
+ chatHistoryManager.load('main').catch((err) => {
89
+ logger.warn?.(`[coclaw] chat history manager load failed: ${String(err?.message ?? err)}`);
90
+ });
86
91
 
87
92
  api.registerChannel({ plugin: coclawChannelPlugin });
93
+
94
+ // 追踪 chat 因 reset 产生的孤儿 session
95
+ if (typeof api.on === 'function') {
96
+ api.on('session_start', async (event, ctx) => {
97
+ if (!event.resumedFrom) return; // 首次创建,无前任
98
+ const agentId = ctx?.agentId ?? 'main';
99
+ const sessionKey = event.sessionKey;
100
+ if (!sessionKey) return;
101
+ try {
102
+ if (!chatHistoryManager.__cache.has(agentId)) {
103
+ await chatHistoryManager.load(agentId);
104
+ }
105
+ await chatHistoryManager.recordArchived({
106
+ agentId,
107
+ sessionKey,
108
+ sessionId: event.resumedFrom,
109
+ });
110
+ } catch (err) {
111
+ logger.warn?.(`[coclaw] chat history record failed: ${String(err?.message ?? err)}`);
112
+ }
113
+ });
114
+ }
115
+
88
116
  api.registerService({
89
117
  id: 'coclaw-realtime-bridge',
90
118
  async start() {
@@ -139,8 +167,9 @@ const plugin = {
139
167
 
140
168
  api.registerGatewayMethod('coclaw.info', async ({ respond }) => {
141
169
  try {
170
+ await waitForSessionsReady();
142
171
  const version = await getPluginVersion();
143
- respond(true, { version, capabilities: ['topics'] });
172
+ respond(true, { version, capabilities: ['topics', 'chatHistory'] });
144
173
  }
145
174
  catch (err) {
146
175
  respondError(respond, err);
@@ -270,6 +299,42 @@ const plugin = {
270
299
  }
271
300
  });
272
301
 
302
+ api.registerGatewayMethod('coclaw.chatHistory.list', async ({ params, respond }) => {
303
+ try {
304
+ const agentId = params?.agentId?.trim?.() || 'main';
305
+ const sessionKey = params?.sessionKey?.trim?.();
306
+ if (!sessionKey) {
307
+ respond(false, { error: 'sessionKey required' });
308
+ return;
309
+ }
310
+ if (!chatHistoryManager.__cache.has(agentId)) {
311
+ await chatHistoryManager.load(agentId);
312
+ }
313
+ const result = await chatHistoryManager.list({ agentId, sessionKey });
314
+ respond(true, result);
315
+ }
316
+ catch (err) {
317
+ respondError(respond, err);
318
+ }
319
+ });
320
+
321
+ // TODO: coclaw.topics.getHistory 未来可废弃,UI 改用 coclaw.sessions.getById
322
+ api.registerGatewayMethod('coclaw.sessions.getById', ({ params, respond }) => {
323
+ try {
324
+ const sessionId = params?.sessionId?.trim?.();
325
+ if (!sessionId) {
326
+ respond(false, { error: 'sessionId required' });
327
+ return;
328
+ }
329
+ const agentId = params?.agentId?.trim?.() || 'main';
330
+ const limit = params?.limit;
331
+ respond(true, manager.getById({ agentId, sessionId, limit }));
332
+ }
333
+ catch (err) {
334
+ respondError(respond, err);
335
+ }
336
+ });
337
+
273
338
  api.registerGatewayMethod('coclaw.upgradeHealth', async ({ respond }) => {
274
339
  try {
275
340
  const { version } = await getPackageInfo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import nodePath from 'node:path';
4
+
5
+ import { atomicWriteJsonFile } from '../utils/atomic-write.js';
6
+ import { createMutex } from '../utils/mutex.js';
7
+
8
+ const HISTORY_FILE = 'coclaw-chat-history.json';
9
+
10
+ function emptyStore() {
11
+ return { version: 1 };
12
+ }
13
+
14
+ /**
15
+ * Chat History 管理器:追踪 chat(sessionKey)因 reset 产生的孤儿 session。
16
+ *
17
+ * 每个 agentId 对应一份 coclaw-chat-history.json,按需懒加载到内存。
18
+ * 写操作通过 mutex + atomicWriteJsonFile 保证一致性。
19
+ *
20
+ * 文件结构示例:
21
+ * {
22
+ * "version": 1,
23
+ * "agent:main:main": [
24
+ * { "sessionId": "xxx", "archivedAt": 1742003000000 }
25
+ * ]
26
+ * }
27
+ */
28
+ export class ChatHistoryManager {
29
+ /**
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
32
+ * @param {object} [opts.logger]
33
+ * @param {Function} [opts.readFile] - 测试注入
34
+ * @param {Function} [opts.writeJsonFile] - 测试注入
35
+ */
36
+ constructor(opts = {}) {
37
+ this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
38
+ this.__logger = opts.logger ?? console;
39
+ this.__readFile = opts.readFile ?? fs.readFile;
40
+ this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
41
+ // 内存缓存:agentId -> { version, [sessionKey]: [...] }
42
+ this.__cache = new Map();
43
+ // 每个 agentId 一把锁
44
+ this.__mutexes = new Map();
45
+ // 进行中的 load Promise(防止并发 load 竞态)
46
+ this.__loadingPromises = new Map();
47
+ }
48
+
49
+ __sessionsDir(agentId) {
50
+ return nodePath.join(this.__rootDir, agentId, 'sessions');
51
+ }
52
+
53
+ __historyFilePath(agentId) {
54
+ return nodePath.join(this.__sessionsDir(agentId), HISTORY_FILE);
55
+ }
56
+
57
+ __mutex(agentId) {
58
+ if (!this.__mutexes.has(agentId)) {
59
+ this.__mutexes.set(agentId, createMutex());
60
+ }
61
+ return this.__mutexes.get(agentId);
62
+ }
63
+
64
+ /**
65
+ * 从磁盘加载指定 agent 的 chat history 到内存。
66
+ * @param {string} agentId
67
+ */
68
+ async load(agentId) {
69
+ if (this.__cache.has(agentId)) return;
70
+ const pending = this.__loadingPromises.get(agentId);
71
+ if (pending) return pending;
72
+
73
+ const p = this.__doLoad(agentId).finally(() => {
74
+ this.__loadingPromises.delete(agentId);
75
+ });
76
+ this.__loadingPromises.set(agentId, p);
77
+ return p;
78
+ }
79
+
80
+ async __doLoad(agentId) {
81
+ const filePath = this.__historyFilePath(agentId);
82
+ try {
83
+ const raw = await this.__readFile(filePath, 'utf8');
84
+ const data = JSON.parse(raw);
85
+ if (data && typeof data === 'object' && typeof data.version === 'number') {
86
+ this.__cache.set(agentId, data);
87
+ return;
88
+ }
89
+ } catch {
90
+ // 文件不存在或解析失败,初始化空数据
91
+ }
92
+ this.__cache.set(agentId, emptyStore());
93
+ }
94
+
95
+ __ensureLoaded(agentId) {
96
+ /* c8 ignore start -- recordArchived/list 均先 __reloadFromDisk,此分支为防御性守卫 */
97
+ if (!this.__cache.has(agentId)) {
98
+ throw new Error(`ChatHistoryManager: agent "${agentId}" not loaded, call load() first`);
99
+ }
100
+ /* c8 ignore stop */
101
+ }
102
+
103
+ __getStore(agentId) {
104
+ this.__ensureLoaded(agentId);
105
+ return this.__cache.get(agentId);
106
+ }
107
+
108
+ async __persist(agentId) {
109
+ const store = this.__getStore(agentId);
110
+ await this.__writeJsonFile(this.__historyFilePath(agentId), store);
111
+ }
112
+
113
+ /**
114
+ * 记录一个被抛弃的孤儿 session
115
+ * @param {{ agentId: string, sessionKey: string, sessionId: string }} params
116
+ */
117
+ async recordArchived({ agentId, sessionKey, sessionId }) {
118
+ if (!sessionKey || !sessionId) return;
119
+ await this.__mutex(agentId).withLock(async () => {
120
+ // 从磁盘重载确保最新状态:list() 无锁覆写 __cache 可能导致缓存过期
121
+ await this.__reloadFromDisk(agentId);
122
+ const store = this.__getStore(agentId);
123
+ if (!Array.isArray(store[sessionKey])) {
124
+ store[sessionKey] = [];
125
+ }
126
+ // 去重:同一 sessionId 不重复记录
127
+ if (store[sessionKey].some((r) => r.sessionId === sessionId)) return;
128
+ // 头部插入(最近的在前)
129
+ store[sessionKey].unshift({
130
+ sessionId,
131
+ archivedAt: Date.now(),
132
+ });
133
+ await this.__persist(agentId);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * 获取指定 chat 的孤儿 session 列表。
139
+ * 每次调用从磁盘重载,确保跨模块实例一致性
140
+ * (OpenClaw 的 hook 和 gateway method 可能在不同 ESM 模块实例中运行)。
141
+ * @param {{ agentId: string, sessionKey: string }} params
142
+ * @returns {Promise<{ history: { sessionId: string, archivedAt: number }[] }>}
143
+ */
144
+ async list({ agentId, sessionKey }) {
145
+ await this.__reloadFromDisk(agentId);
146
+ const store = this.__getStore(agentId);
147
+ const history = Array.isArray(store[sessionKey]) ? store[sessionKey] : [];
148
+ return { history };
149
+ }
150
+
151
+ /**
152
+ * 从磁盘重载指定 agent 的数据到内存(覆盖缓存)
153
+ * @param {string} agentId
154
+ */
155
+ async __reloadFromDisk(agentId) {
156
+ const filePath = this.__historyFilePath(agentId);
157
+ try {
158
+ const raw = await this.__readFile(filePath, 'utf8');
159
+ const data = JSON.parse(raw);
160
+ if (data && typeof data === 'object' && typeof data.version === 'number') {
161
+ this.__cache.set(agentId, data);
162
+ return;
163
+ }
164
+ } catch {
165
+ // 文件不存在或解析失败
166
+ }
167
+ if (!this.__cache.has(agentId)) {
168
+ this.__cache.set(agentId, emptyStore());
169
+ }
170
+ }
171
+ }
@@ -479,7 +479,7 @@ export class RealtimeBridge {
479
479
  this.gatewayReady = true;
480
480
  this.__logDebug(`gateway connect ok <- id=${payload.id}`);
481
481
  this.gatewayConnectReqId = null;
482
- void this.__ensureAllAgentSessions();
482
+ this.__ensureSessionsPromise = this.__ensureAllAgentSessions();
483
483
  }
484
484
  else {
485
485
  this.gatewayReady = false;
@@ -814,6 +814,11 @@ export async function stopRealtimeBridge() {
814
814
  singleton = null; // 置 null 后须通过 restartRealtimeBridge 重建
815
815
  }
816
816
 
817
+ export async function waitForSessionsReady() {
818
+ if (!singleton?.__ensureSessionsPromise) return;
819
+ await singleton.__ensureSessionsPromise;
820
+ }
821
+
817
822
  export async function ensureAgentSession(agentId) {
818
823
  if (!singleton) {
819
824
  return { ok: false, error: 'bridge_not_started' };
@@ -1,4 +1,3 @@
1
- /* c8 ignore start */
2
1
  import fs from 'node:fs';
3
2
  import os from 'node:os';
4
3
  import nodePath from 'node:path';
@@ -25,6 +24,7 @@ const CRON_TIME_UTC_RE = /(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/;
25
24
  function formatCronTime(matchedText) {
26
25
  const m = matchedText.match(CRON_TIME_UTC_RE);
27
26
  if (!m) return '';
27
+ /* c8 ignore start -- Date 构造在正则已校验的输入下不会抛出 */
28
28
  try {
29
29
  const d = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00Z`);
30
30
  if (!Number.isFinite(d.getTime())) return '';
@@ -38,6 +38,7 @@ function formatCronTime(matchedText) {
38
38
  catch {
39
39
  return '';
40
40
  }
41
+ /* c8 ignore stop */
41
42
  }
42
43
 
43
44
  function stripLeadingPattern(text, re) {
@@ -50,6 +51,7 @@ function stripLeadingPattern(text, re) {
50
51
  }
51
52
 
52
53
  function cleanTitleText(text) {
54
+ /* c8 ignore next */
53
55
  if (!text) return '';
54
56
  let s = stripLeadingPattern(text, INBOUND_META_RE);
55
57
  s = stripLeadingPattern(s, OPERATOR_POLICY_RE);
@@ -127,10 +129,12 @@ function truncateTitle(text, maxLen = DERIVED_TITLE_MAX_LEN) {
127
129
 
128
130
  function extractRawTextFromContent(content) {
129
131
  if (typeof content === 'string') return content;
132
+ /* c8 ignore next */
130
133
  if (!Array.isArray(content)) return undefined;
131
134
  for (const part of content) {
132
135
  if (!part || typeof part !== 'object') continue;
133
136
  if (part.type !== 'text') continue;
137
+ /* c8 ignore next */
134
138
  if (typeof part.text !== 'string') continue;
135
139
  if (part.text.trim()) return part.text;
136
140
  }
@@ -149,6 +153,7 @@ function findFirstUserRawText(filePath, logger) {
149
153
  if (raw && raw.trim()) return raw;
150
154
  }
151
155
  catch (err) {
156
+ /* c8 ignore next -- ?./?? fallback */
152
157
  logger.warn?.(`[session-manager] bad json line skipped when deriving title: ${String(err?.message ?? err)}`);
153
158
  }
154
159
  }
@@ -161,15 +166,18 @@ function deriveTitle(filePath, logger) {
161
166
  const cleaned = cleanTitleText(rawText);
162
167
  if (!cleaned) return undefined;
163
168
  const normalized = cleaned.replace(/\s+/g, ' ').trim();
169
+ /* c8 ignore next */
164
170
  if (!normalized) return undefined;
165
171
  return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
166
172
  }
167
173
 
168
174
  export function createSessionManager(options = {}) {
169
175
  const rootDir = options.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
176
+ /* c8 ignore next */
170
177
  const logger = options.logger ?? console;
171
178
 
172
179
  function sessionsDir(agentId = 'main') {
180
+ /* c8 ignore next */
173
181
  const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
174
182
  return nodePath.join(rootDir, aid, 'sessions');
175
183
  }
@@ -177,6 +185,7 @@ export function createSessionManager(options = {}) {
177
185
  function readIndex(agentId = 'main') {
178
186
  const file = nodePath.join(sessionsDir(agentId), 'sessions.json');
179
187
  const data = readJsonSafe(file, {});
188
+ /* c8 ignore next */
180
189
  if (!data || typeof data !== 'object') return {};
181
190
  return data;
182
191
  }
@@ -225,6 +234,7 @@ export function createSessionManager(options = {}) {
225
234
  // 补充 sessions.json 中有索引但无 transcript 文件的 session(如 reset 后未对话、新建 session)
226
235
  for (const [sessionKey, entry] of Object.entries(index)) {
227
236
  const sid = entry?.sessionId;
237
+ /* c8 ignore next -- !sid 防御性检查 */
228
238
  if (!sid || grouped.has(sid)) continue;
229
239
  grouped.set(sid, {
230
240
  sessionId: sid,
@@ -289,6 +299,7 @@ export function createSessionManager(options = {}) {
289
299
  if (a.archiveStamp !== b.archiveStamp) {
290
300
  return b.archiveStamp.localeCompare(a.archiveStamp);
291
301
  }
302
+ /* c8 ignore next -- 同一 sessionId 的 reset 文件不会有相同 archiveStamp */
292
303
  return b.updatedAt - a.updatedAt;
293
304
  });
294
305
  if (resetCandidates.length > 0) {
@@ -314,10 +325,12 @@ export function createSessionManager(options = {}) {
314
325
  all.push(JSON.parse(line));
315
326
  }
316
327
  catch (err) {
328
+ /* c8 ignore next -- ?./?? fallback */
317
329
  logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
318
330
  }
319
331
  }
320
332
  const messages = all.slice(cursor, cursor + limit);
333
+ /* c8 ignore next */
321
334
  const nextCursor = cursor + limit < all.length ? String(cursor + limit) : null;
322
335
  return {
323
336
  agentId,
@@ -329,6 +342,40 @@ export function createSessionManager(options = {}) {
329
342
  };
330
343
  }
331
344
 
332
- return { listAll, get };
345
+ /**
346
+ * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
347
+ * 只返回 type==="message" 且有合法 message.role 的行。
348
+ * @param {{ sessionId: string, agentId?: string, limit?: number }} params
349
+ * @returns {{ messages: object[] }}
350
+ */
351
+ function getById(params = {}) {
352
+ const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
353
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
354
+ if (!sessionId) throw new Error('sessionId required');
355
+ const limit = clamp(params.limit, 1, 500, 500);
356
+ const file = resolveTranscriptFile(agentId, sessionId);
357
+ if (!file) {
358
+ return { messages: [] };
359
+ }
360
+
361
+ const messages = [];
362
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean)) {
363
+ try {
364
+ const row = JSON.parse(line);
365
+ if (row?.type !== 'message') continue;
366
+ const msg = row?.message;
367
+ if (!msg || typeof msg !== 'object' || !msg.role) continue;
368
+ messages.push(row);
369
+ }
370
+ catch (err) {
371
+ /* c8 ignore next -- ?./?? fallback */
372
+ logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
373
+ }
374
+ }
375
+ // 取最后 limit 条
376
+ const sliced = messages.length > limit ? messages.slice(-limit) : messages;
377
+ return { messages: sliced };
378
+ }
379
+
380
+ return { listAll, get, getById };
333
381
  }
334
- /* c8 ignore stop */