@coclaw/openclaw-coclaw 0.3.2 → 0.4.0
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 +131 -1
- package/package.json +1 -1
- package/src/realtime-bridge.js +87 -0
- package/src/topic-manager/manager.js +237 -0
- package/src/topic-manager/title-gen.js +147 -0
- package/src/utils/atomic-write.js +61 -0
- package/src/utils/mutex.js +50 -0
package/index.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
|
|
1
4
|
import { bindBot, unbindBot } from './src/common/bot-binding.js';
|
|
2
5
|
import { registerCoclawCli } from './src/cli-registrar.js';
|
|
3
6
|
import { resolveErrorMessage } from './src/common/errors.js';
|
|
4
7
|
import { notBound, bindOk, unbindOk } from './src/common/messages.js';
|
|
5
8
|
import { coclawChannelPlugin } from './src/channel-plugin.js';
|
|
6
|
-
import { ensureAgentSession, restartRealtimeBridge, stopRealtimeBridge } from './src/realtime-bridge.js';
|
|
9
|
+
import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge } from './src/realtime-bridge.js';
|
|
7
10
|
import { setRuntime } from './src/runtime.js';
|
|
8
11
|
import { createSessionManager } from './src/session-manager/manager.js';
|
|
12
|
+
import { TopicManager } from './src/topic-manager/manager.js';
|
|
13
|
+
import { generateTitle } from './src/topic-manager/title-gen.js';
|
|
9
14
|
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
10
15
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
11
16
|
|
|
17
|
+
// 延迟读取 + 缓存:避免模块加载时 package.json 损坏导致插件整体无法注册
|
|
18
|
+
let __pluginVersion = null;
|
|
19
|
+
export async function getPluginVersion() {
|
|
20
|
+
if (__pluginVersion) return __pluginVersion;
|
|
21
|
+
try {
|
|
22
|
+
const pkgPath = nodePath.resolve(import.meta.dirname, 'package.json');
|
|
23
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
24
|
+
__pluginVersion = JSON.parse(raw).version ?? 'unknown';
|
|
25
|
+
} catch {
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
return __pluginVersion;
|
|
29
|
+
}
|
|
30
|
+
// 测试用:重置缓存
|
|
31
|
+
export function __resetPluginVersion() { __pluginVersion = null; }
|
|
32
|
+
|
|
12
33
|
|
|
13
34
|
function parseCommandArgs(args) {
|
|
14
35
|
/* c8 ignore next */
|
|
@@ -56,6 +77,12 @@ const plugin = {
|
|
|
56
77
|
setRuntime(api.runtime);
|
|
57
78
|
const logger = api?.logger ?? console;
|
|
58
79
|
const manager = createSessionManager({ logger });
|
|
80
|
+
const topicManager = new TopicManager({ logger });
|
|
81
|
+
|
|
82
|
+
// 懒加载 topic 数据(best-effort,不阻断注册)
|
|
83
|
+
topicManager.load('main').catch((err) => {
|
|
84
|
+
logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
|
|
85
|
+
});
|
|
59
86
|
|
|
60
87
|
api.registerChannel({ plugin: coclawChannelPlugin });
|
|
61
88
|
api.registerService({
|
|
@@ -110,6 +137,109 @@ const plugin = {
|
|
|
110
137
|
}
|
|
111
138
|
});
|
|
112
139
|
|
|
140
|
+
api.registerGatewayMethod('coclaw.info', async ({ respond }) => {
|
|
141
|
+
try {
|
|
142
|
+
const version = await getPluginVersion();
|
|
143
|
+
respond(true, { version, capabilities: ['topics'] });
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
respondError(respond, err);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
api.registerGatewayMethod('coclaw.topics.create', async ({ params, respond }) => {
|
|
151
|
+
try {
|
|
152
|
+
const agentId = params?.agentId?.trim?.() || 'main';
|
|
153
|
+
// 确保该 agent 的 topics 已加载
|
|
154
|
+
if (!topicManager.__cache.has(agentId)) {
|
|
155
|
+
await topicManager.load(agentId);
|
|
156
|
+
}
|
|
157
|
+
const result = await topicManager.create({ agentId });
|
|
158
|
+
respond(true, result);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
respondError(respond, err);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
api.registerGatewayMethod('coclaw.topics.list', async ({ params, respond }) => {
|
|
166
|
+
try {
|
|
167
|
+
const agentId = params?.agentId?.trim?.() || 'main';
|
|
168
|
+
if (!topicManager.__cache.has(agentId)) {
|
|
169
|
+
await topicManager.load(agentId);
|
|
170
|
+
}
|
|
171
|
+
respond(true, topicManager.list({ agentId }));
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
respondError(respond, err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
api.registerGatewayMethod('coclaw.topics.get', ({ params, respond }) => {
|
|
179
|
+
try {
|
|
180
|
+
const topicId = params?.topicId?.trim?.();
|
|
181
|
+
if (!topicId) {
|
|
182
|
+
respond(false, { error: 'topicId required' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
respond(true, topicManager.get({ topicId }));
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
respondError(respond, err);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
api.registerGatewayMethod('coclaw.topics.getHistory', ({ params, respond }) => {
|
|
193
|
+
try {
|
|
194
|
+
const topicId = params?.topicId?.trim?.();
|
|
195
|
+
if (!topicId) {
|
|
196
|
+
respond(false, { error: 'topicId required' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const agentId = params?.agentId?.trim?.() || 'main';
|
|
200
|
+
// 直接复用 session-manager 的 get(),topicId 即 sessionId
|
|
201
|
+
respond(true, manager.get({ agentId, sessionId: topicId }));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
respondError(respond, err);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
api.registerGatewayMethod('coclaw.topics.generateTitle', async ({ params, respond }) => {
|
|
209
|
+
try {
|
|
210
|
+
const topicId = params?.topicId?.trim?.();
|
|
211
|
+
if (!topicId) {
|
|
212
|
+
respond(false, { error: 'topicId required' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const result = await generateTitle({
|
|
216
|
+
topicId,
|
|
217
|
+
topicManager,
|
|
218
|
+
agentRpc: gatewayAgentRpc,
|
|
219
|
+
logger,
|
|
220
|
+
});
|
|
221
|
+
respond(true, result);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
respondError(respond, err);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
api.registerGatewayMethod('coclaw.topics.delete', async ({ params, respond }) => {
|
|
229
|
+
try {
|
|
230
|
+
const topicId = params?.topicId?.trim?.();
|
|
231
|
+
if (!topicId) {
|
|
232
|
+
respond(false, { error: 'topicId required' });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const result = await topicManager.delete({ topicId });
|
|
236
|
+
respond(true, result);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
respondError(respond, err);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
113
243
|
api.registerGatewayMethod('coclaw.upgradeHealth', async ({ respond }) => {
|
|
114
244
|
try {
|
|
115
245
|
const { version } = await getPackageInfo();
|
package/package.json
CHANGED
package/src/realtime-bridge.js
CHANGED
|
@@ -246,6 +246,79 @@ export class RealtimeBridge {
|
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* 两阶段 agent RPC:发送请求后等待 accepted 再等待最终响应。
|
|
251
|
+
* agent() RPC 返回两次响应(同一 id):
|
|
252
|
+
* 1. { status: "accepted", runId }
|
|
253
|
+
* 2. { status: "ok", result: { payloads: [{ text }] } }
|
|
254
|
+
*
|
|
255
|
+
* @param {string} method - RPC 方法名(通常为 'agent')
|
|
256
|
+
* @param {object} params - RPC 参数
|
|
257
|
+
* @param {object} [options]
|
|
258
|
+
* @param {number} [options.timeoutMs=60000] - 总超时(含两阶段)
|
|
259
|
+
* @param {number} [options.acceptTimeoutMs=10000] - 等待 accepted 的超时
|
|
260
|
+
* @returns {Promise<{ok: boolean, response?: object, error?: string}>}
|
|
261
|
+
*/
|
|
262
|
+
async __gatewayAgentRpc(method, params = {}, options = {}) {
|
|
263
|
+
const totalTimeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 60_000;
|
|
264
|
+
const acceptTimeoutMs = Number.isFinite(options.acceptTimeoutMs) ? options.acceptTimeoutMs : 10_000;
|
|
265
|
+
const ready = await this.__waitGatewayReady(acceptTimeoutMs);
|
|
266
|
+
/* c8 ignore next 3 -- waitGatewayReady 返回 false 后的防御检查 */
|
|
267
|
+
if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1 || !this.gatewayReady) {
|
|
268
|
+
return { ok: false, error: 'gateway_not_ready' };
|
|
269
|
+
}
|
|
270
|
+
const ws = this.gatewayWs;
|
|
271
|
+
const id = this.__nextGatewayReqId('coclaw-agent');
|
|
272
|
+
return await new Promise((resolve) => {
|
|
273
|
+
let settled = false;
|
|
274
|
+
let accepted = false;
|
|
275
|
+
let totalTimer = null;
|
|
276
|
+
let acceptTimer = null;
|
|
277
|
+
const finish = (result) => {
|
|
278
|
+
if (settled) return;
|
|
279
|
+
settled = true;
|
|
280
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
281
|
+
if (acceptTimer) clearTimeout(acceptTimer);
|
|
282
|
+
this.gatewayPendingRequests.delete(id);
|
|
283
|
+
resolve(result);
|
|
284
|
+
};
|
|
285
|
+
// 两阶段 settle:第一次 accepted 不 resolve,第二次才 resolve
|
|
286
|
+
const settle = (result) => {
|
|
287
|
+
if (settled) return;
|
|
288
|
+
// 错误响应:直接结束
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
finish(result);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const status = result.response?.payload?.status;
|
|
294
|
+
if (!accepted && status === 'accepted') {
|
|
295
|
+
// 第一阶段:已接受,切换到总超时
|
|
296
|
+
accepted = true;
|
|
297
|
+
if (acceptTimer) clearTimeout(acceptTimer);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// 第二阶段或非 accepted 响应:最终结果
|
|
301
|
+
finish(result);
|
|
302
|
+
};
|
|
303
|
+
this.gatewayPendingRequests.set(id, settle);
|
|
304
|
+
// 总超时
|
|
305
|
+
totalTimer = setTimeout(() => finish({ ok: false, error: 'timeout' }), totalTimeoutMs);
|
|
306
|
+
totalTimer.unref?.();
|
|
307
|
+
// accepted 超时(仅等第一阶段)
|
|
308
|
+
acceptTimer = setTimeout(() => {
|
|
309
|
+
if (!accepted) finish({ ok: false, error: 'accept_timeout' });
|
|
310
|
+
}, acceptTimeoutMs);
|
|
311
|
+
acceptTimer.unref?.();
|
|
312
|
+
try {
|
|
313
|
+
ws.send(JSON.stringify({ type: 'req', id, method, params }));
|
|
314
|
+
}
|
|
315
|
+
/* c8 ignore next 3 -- ws.send 极少抛出 */
|
|
316
|
+
catch {
|
|
317
|
+
finish({ ok: false, error: 'send_failed' });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
249
322
|
/**
|
|
250
323
|
* 确保指定 agent 的主 session 存在(sessions.resolve + 条件 sessions.reset)
|
|
251
324
|
* @param {string} [agentId] - agent ID,默认 'main'
|
|
@@ -747,3 +820,17 @@ export async function ensureAgentSession(agentId) {
|
|
|
747
820
|
}
|
|
748
821
|
return singleton.ensureAgentSession(agentId);
|
|
749
822
|
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 通过 gateway WS 发起两阶段 agent RPC(供标题生成等场景使用)
|
|
826
|
+
* @param {string} method
|
|
827
|
+
* @param {object} params
|
|
828
|
+
* @param {object} [options]
|
|
829
|
+
* @returns {Promise<{ok: boolean, response?: object, error?: string}>}
|
|
830
|
+
*/
|
|
831
|
+
export async function gatewayAgentRpc(method, params, options) {
|
|
832
|
+
if (!singleton) {
|
|
833
|
+
return { ok: false, error: 'bridge_not_started' };
|
|
834
|
+
}
|
|
835
|
+
return singleton.__gatewayAgentRpc(method, params, options);
|
|
836
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import nodePath from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { atomicWriteJsonFile } from '../utils/atomic-write.js';
|
|
7
|
+
import { createMutex } from '../utils/mutex.js';
|
|
8
|
+
|
|
9
|
+
const TOPICS_FILE = 'coclaw-topics.json';
|
|
10
|
+
|
|
11
|
+
function emptyStore() {
|
|
12
|
+
return { version: 1, topics: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Topic 管理器:内存模型 + CRUD + 磁盘持久化。
|
|
17
|
+
*
|
|
18
|
+
* 每个 agentId 对应一份 coclaw-topics.json,按需懒加载到内存。
|
|
19
|
+
* 写操作通过 mutex + atomicWriteJsonFile 保证一致性。
|
|
20
|
+
*/
|
|
21
|
+
export class TopicManager {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
|
|
25
|
+
* @param {object} [opts.logger]
|
|
26
|
+
* @param {Function} [opts.readFile] - 测试注入
|
|
27
|
+
* @param {Function} [opts.writeJsonFile] - 测试注入
|
|
28
|
+
* @param {Function} [opts.unlinkFile] - 测试注入
|
|
29
|
+
* @param {Function} [opts.copyFile] - 测试注入
|
|
30
|
+
*/
|
|
31
|
+
constructor(opts = {}) {
|
|
32
|
+
this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
|
|
33
|
+
this.__logger = opts.logger ?? console;
|
|
34
|
+
this.__readFile = opts.readFile ?? fs.readFile;
|
|
35
|
+
this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
|
|
36
|
+
this.__unlinkFile = opts.unlinkFile ?? fs.unlink;
|
|
37
|
+
this.__copyFile = opts.copyFile ?? fs.copyFile;
|
|
38
|
+
// 内存缓存:agentId -> { version, topics[] }
|
|
39
|
+
this.__cache = new Map();
|
|
40
|
+
// 每个 agentId 一把锁
|
|
41
|
+
this.__mutexes = new Map();
|
|
42
|
+
// 进行中的 load Promise(防止并发 load 竞态)
|
|
43
|
+
this.__loadingPromises = new Map();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
__sessionsDir(agentId) {
|
|
47
|
+
return nodePath.join(this.__rootDir, agentId, 'sessions');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
__topicsFilePath(agentId) {
|
|
51
|
+
return nodePath.join(this.__sessionsDir(agentId), TOPICS_FILE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
__mutex(agentId) {
|
|
55
|
+
if (!this.__mutexes.has(agentId)) {
|
|
56
|
+
this.__mutexes.set(agentId, createMutex());
|
|
57
|
+
}
|
|
58
|
+
return this.__mutexes.get(agentId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 从磁盘加载指定 agent 的 topics 到内存(文件不存在时初始化空数据)。
|
|
63
|
+
* 若同一 agentId 的 load 已在进行中,复用同一 Promise,防止并发竞态。
|
|
64
|
+
* @param {string} agentId
|
|
65
|
+
*/
|
|
66
|
+
async load(agentId) {
|
|
67
|
+
// 已加载 → 跳过
|
|
68
|
+
if (this.__cache.has(agentId)) return;
|
|
69
|
+
// 正在加载 → 复用
|
|
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.__topicsFilePath(agentId);
|
|
82
|
+
try {
|
|
83
|
+
const raw = await this.__readFile(filePath, 'utf8');
|
|
84
|
+
const data = JSON.parse(raw);
|
|
85
|
+
if (data && typeof data === 'object' && Array.isArray(data.topics)) {
|
|
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
|
+
if (!this.__cache.has(agentId)) {
|
|
97
|
+
throw new Error(`TopicManager: agent "${agentId}" not loaded, call load() first`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
__getStore(agentId) {
|
|
102
|
+
this.__ensureLoaded(agentId);
|
|
103
|
+
return this.__cache.get(agentId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async __persist(agentId) {
|
|
107
|
+
const store = this.__getStore(agentId);
|
|
108
|
+
await this.__writeJsonFile(this.__topicsFilePath(agentId), store);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 创建新 Topic
|
|
113
|
+
* @param {{ agentId: string }} params
|
|
114
|
+
* @returns {Promise<{ topicId: string }>}
|
|
115
|
+
*/
|
|
116
|
+
async create({ agentId }) {
|
|
117
|
+
const topicId = randomUUID();
|
|
118
|
+
const record = {
|
|
119
|
+
topicId,
|
|
120
|
+
agentId,
|
|
121
|
+
title: null,
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
};
|
|
124
|
+
await this.__mutex(agentId).withLock(async () => {
|
|
125
|
+
const store = this.__getStore(agentId);
|
|
126
|
+
store.topics.unshift(record);
|
|
127
|
+
await this.__persist(agentId);
|
|
128
|
+
});
|
|
129
|
+
return { topicId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 获取指定 agent 的 Topic 列表(已按 createdAt 倒序)
|
|
134
|
+
* @param {{ agentId: string }} params
|
|
135
|
+
* @returns {{ topics: object[] }}
|
|
136
|
+
*/
|
|
137
|
+
list({ agentId }) {
|
|
138
|
+
const store = this.__getStore(agentId);
|
|
139
|
+
return { topics: store.topics };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 获取单个 Topic 元信息
|
|
144
|
+
* @param {{ topicId: string }} params
|
|
145
|
+
* @returns {{ topic: object | null }}
|
|
146
|
+
*/
|
|
147
|
+
get({ topicId }) {
|
|
148
|
+
for (const [, store] of this.__cache) {
|
|
149
|
+
const found = store.topics.find((t) => t.topicId === topicId);
|
|
150
|
+
if (found) return { topic: found };
|
|
151
|
+
}
|
|
152
|
+
return { topic: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 更新 Topic 标题
|
|
157
|
+
* @param {{ topicId: string, title: string }} params
|
|
158
|
+
*/
|
|
159
|
+
async updateTitle({ topicId, title }) {
|
|
160
|
+
// 查找 topic 所属 agentId
|
|
161
|
+
let targetAgentId = null;
|
|
162
|
+
for (const [agentId, store] of this.__cache) {
|
|
163
|
+
if (store.topics.some((t) => t.topicId === topicId)) {
|
|
164
|
+
targetAgentId = agentId;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!targetAgentId) {
|
|
169
|
+
throw new Error(`Topic not found: ${topicId}`);
|
|
170
|
+
}
|
|
171
|
+
await this.__mutex(targetAgentId).withLock(async () => {
|
|
172
|
+
const store = this.__getStore(targetAgentId);
|
|
173
|
+
const topic = store.topics.find((t) => t.topicId === topicId);
|
|
174
|
+
if (!topic) throw new Error(`Topic not found: ${topicId}`);
|
|
175
|
+
topic.title = title;
|
|
176
|
+
await this.__persist(targetAgentId);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 删除 Topic 及其 .jsonl 文件
|
|
182
|
+
* @param {{ topicId: string }} params
|
|
183
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
184
|
+
*/
|
|
185
|
+
async delete({ topicId }) {
|
|
186
|
+
let targetAgentId = null;
|
|
187
|
+
for (const [agentId, store] of this.__cache) {
|
|
188
|
+
if (store.topics.some((t) => t.topicId === topicId)) {
|
|
189
|
+
targetAgentId = agentId;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!targetAgentId) {
|
|
194
|
+
return { ok: false };
|
|
195
|
+
}
|
|
196
|
+
await this.__mutex(targetAgentId).withLock(async () => {
|
|
197
|
+
const store = this.__getStore(targetAgentId);
|
|
198
|
+
const idx = store.topics.findIndex((t) => t.topicId === topicId);
|
|
199
|
+
if (idx === -1) return;
|
|
200
|
+
store.topics.splice(idx, 1);
|
|
201
|
+
await this.__persist(targetAgentId);
|
|
202
|
+
});
|
|
203
|
+
// 删除 .jsonl(忽略不存在)
|
|
204
|
+
const jsonlPath = nodePath.join(this.__sessionsDir(targetAgentId), `${topicId}.jsonl`);
|
|
205
|
+
try {
|
|
206
|
+
await this.__unlinkFile(jsonlPath);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
209
|
+
}
|
|
210
|
+
return { ok: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 复制 topic 的 .jsonl 为临时文件(用于标题生成)
|
|
215
|
+
* @param {{ agentId: string, topicId: string }} params
|
|
216
|
+
* @returns {Promise<{ tempId: string, tempPath: string }>}
|
|
217
|
+
*/
|
|
218
|
+
async copyTranscript({ agentId, topicId }) {
|
|
219
|
+
const srcPath = nodePath.join(this.__sessionsDir(agentId), `${topicId}.jsonl`);
|
|
220
|
+
const tempId = randomUUID();
|
|
221
|
+
const tempPath = nodePath.join(this.__sessionsDir(agentId), `${tempId}.jsonl`);
|
|
222
|
+
await this.__copyFile(srcPath, tempPath);
|
|
223
|
+
return { tempId, tempPath };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 删除临时 .jsonl 文件
|
|
228
|
+
* @param {string} filePath
|
|
229
|
+
*/
|
|
230
|
+
async cleanupTempFile(filePath) {
|
|
231
|
+
try {
|
|
232
|
+
await this.__unlinkFile(filePath);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const TITLE_SYSTEM_PROMPT = [
|
|
4
|
+
'You are a title generator. Your ONLY job is to output a short title.',
|
|
5
|
+
'',
|
|
6
|
+
'STRICT RULES:',
|
|
7
|
+
'- Output ONLY the title itself. Nothing else.',
|
|
8
|
+
'- Do NOT add any prefix like "Title:", "标题:", "Here is the title:", "好的" etc.',
|
|
9
|
+
'- Do NOT add quotes around the title.',
|
|
10
|
+
'- Do NOT add any explanation, greeting, or preamble.',
|
|
11
|
+
'- Do NOT call any tools or functions.',
|
|
12
|
+
'- Keep it concise: max 15 words.',
|
|
13
|
+
'- Use the same language as the conversation.',
|
|
14
|
+
'',
|
|
15
|
+
'GOOD examples of valid output:',
|
|
16
|
+
' 量子计算基础概念',
|
|
17
|
+
' How to deploy a Node.js app',
|
|
18
|
+
'',
|
|
19
|
+
'BAD examples (DO NOT do this):',
|
|
20
|
+
' 好的,标题是:量子计算基础概念',
|
|
21
|
+
' "量子计算基础概念"',
|
|
22
|
+
' Title: How to deploy a Node.js app',
|
|
23
|
+
].join('\n');
|
|
24
|
+
|
|
25
|
+
const TITLE_MAX_LEN = 128;
|
|
26
|
+
|
|
27
|
+
// 清洗 LLM 返回的标题文本
|
|
28
|
+
const QUOTE_PAIRS = [
|
|
29
|
+
['"', '"'],
|
|
30
|
+
["'", "'"],
|
|
31
|
+
['\u300C', '\u300D'], // 「」
|
|
32
|
+
['\u201C', '\u201D'], // ""
|
|
33
|
+
['\u2018', '\u2019'], // ''
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// 弱模型常见的前缀模式(贪婪匹配到冒号/换行后的实际标题)
|
|
37
|
+
const PREFIX_PATTERNS = [
|
|
38
|
+
// 中文前缀:好的,标题是:xxx / 以下是标题:xxx / 标题:xxx
|
|
39
|
+
/^(?:好的[,,]?\s*)?(?:以下是|这是|生成的)?(?:对话)?标题(?:是|为)?[::]\s*/,
|
|
40
|
+
// 英文前缀:Title: xxx / Here is the title: xxx / Sure, the title is: xxx
|
|
41
|
+
/^(?:sure[,.]?\s*)?(?:here\s+is\s+)?(?:the\s+)?title(?:\s+is)?[:\s]+/i,
|
|
42
|
+
// 通用客气开头:好的,让我... / 好的,xxx
|
|
43
|
+
/^好的[,,]\s*(?:让我[^::]*[::]|我[^::]*[::])\s*/,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function cleanTitle(raw) {
|
|
47
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
48
|
+
let s = raw.trim();
|
|
49
|
+
// 多行响应时只取第一行(LLM 可能附加解释)
|
|
50
|
+
const firstLine = s.split(/\r?\n/)[0].trim();
|
|
51
|
+
if (firstLine) s = firstLine;
|
|
52
|
+
// 去除常见前缀
|
|
53
|
+
for (const re of PREFIX_PATTERNS) {
|
|
54
|
+
s = s.replace(re, '');
|
|
55
|
+
}
|
|
56
|
+
s = s.trim();
|
|
57
|
+
// 去除首尾成对引号
|
|
58
|
+
for (const [open, close] of QUOTE_PAIRS) {
|
|
59
|
+
if (s.startsWith(open) && s.endsWith(close) && s.length >= 2) {
|
|
60
|
+
s = s.slice(open.length, -close.length).trim();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 截断
|
|
64
|
+
if (s.length > TITLE_MAX_LEN) {
|
|
65
|
+
s = s.slice(0, TITLE_MAX_LEN);
|
|
66
|
+
}
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 从 agent 两阶段响应中提取 assistant 文本
|
|
72
|
+
* @param {object} response - gateway 响应
|
|
73
|
+
* @returns {string | null}
|
|
74
|
+
*/
|
|
75
|
+
function extractAssistantText(response) {
|
|
76
|
+
const payloads = response?.payload?.result?.payloads;
|
|
77
|
+
if (!Array.isArray(payloads)) return null;
|
|
78
|
+
for (const p of payloads) {
|
|
79
|
+
if (typeof p?.text === 'string' && p.text.trim()) {
|
|
80
|
+
return p.text.trim();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 为 Topic 生成 AI 标题
|
|
88
|
+
*
|
|
89
|
+
* @param {object} opts
|
|
90
|
+
* @param {string} opts.topicId
|
|
91
|
+
* @param {object} opts.topicManager - TopicManager 实例
|
|
92
|
+
* @param {Function} opts.agentRpc - gatewayAgentRpc 函数
|
|
93
|
+
* @param {object} [opts.logger]
|
|
94
|
+
* @returns {Promise<{ title: string }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function generateTitle({ topicId, topicManager, agentRpc, logger }) {
|
|
97
|
+
const log = logger ?? console;
|
|
98
|
+
|
|
99
|
+
// 验证 topic 存在
|
|
100
|
+
const { topic } = topicManager.get({ topicId });
|
|
101
|
+
if (!topic) {
|
|
102
|
+
throw new Error(`Topic not found: ${topicId}`);
|
|
103
|
+
}
|
|
104
|
+
const { agentId } = topic;
|
|
105
|
+
|
|
106
|
+
// 复制 .jsonl → 临时文件
|
|
107
|
+
const { tempId, tempPath } = await topicManager.copyTranscript({ agentId, topicId });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// 通过 gateway WS 发起 agent 两阶段请求
|
|
111
|
+
const result = await agentRpc('agent', {
|
|
112
|
+
sessionId: tempId,
|
|
113
|
+
extraSystemPrompt: TITLE_SYSTEM_PROMPT,
|
|
114
|
+
message: '请为这段对话生成标题',
|
|
115
|
+
idempotencyKey: randomUUID(),
|
|
116
|
+
}, {
|
|
117
|
+
timeoutMs: 60_000,
|
|
118
|
+
acceptTimeoutMs: 10_000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
throw new Error(`Agent RPC failed: ${result.error ?? 'unknown'}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rawTitle = extractAssistantText(result.response);
|
|
126
|
+
if (!rawTitle) {
|
|
127
|
+
throw new Error('No assistant text in agent response');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const title = cleanTitle(rawTitle);
|
|
131
|
+
if (!title) {
|
|
132
|
+
throw new Error('Title is empty after cleaning');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 更新 topic title
|
|
136
|
+
await topicManager.updateTitle({ topicId, title });
|
|
137
|
+
return { title };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log.warn?.(`[coclaw] generateTitle failed for topic ${topicId}: ${String(err?.message ?? err)}`);
|
|
140
|
+
throw err;
|
|
141
|
+
} finally {
|
|
142
|
+
// 清理临时文件
|
|
143
|
+
await topicManager.cleanupTempFile(tempPath).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { cleanTitle, extractAssistantText, TITLE_SYSTEM_PROMPT };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原子文件写入工具。
|
|
3
|
+
* 通过 write-to-tmp + rename 模式确保写入过程中崩溃不会损坏目标文件。
|
|
4
|
+
*
|
|
5
|
+
* 参照 OpenClaw writeTextAtomic / writeJsonAtomic 实现。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import nodePath from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 原子写入任意文件。
|
|
14
|
+
* 先写入同目录临时文件,再 rename 覆盖目标(POSIX 原子操作)。
|
|
15
|
+
*
|
|
16
|
+
* @param {string} filePath - 目标文件路径
|
|
17
|
+
* @param {string | Buffer} content - 文件内容
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @param {number} [opts.mode=0o600] - 文件权限
|
|
20
|
+
* @param {number} [opts.dirMode] - 父目录权限(自动创建时使用)
|
|
21
|
+
* @param {string} [opts.encoding='utf8'] - 写入编码
|
|
22
|
+
*/
|
|
23
|
+
async function atomicWriteFile(filePath, content, opts) {
|
|
24
|
+
const mode = opts?.mode ?? 0o600;
|
|
25
|
+
const encoding = opts?.encoding ?? 'utf8';
|
|
26
|
+
const mkdirOpts = { recursive: true };
|
|
27
|
+
if (opts?.dirMode != null) {
|
|
28
|
+
mkdirOpts.mode = opts.dirMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await fs.mkdir(nodePath.dirname(filePath), mkdirOpts);
|
|
32
|
+
|
|
33
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
34
|
+
try {
|
|
35
|
+
await fs.writeFile(tmp, content, { encoding, mode });
|
|
36
|
+
// best-effort chmod(部分平台 writeFile 的 mode 可能不生效)
|
|
37
|
+
try { await fs.chmod(tmp, mode); } catch { /* ignore */ }
|
|
38
|
+
await fs.rename(tmp, filePath);
|
|
39
|
+
try { await fs.chmod(filePath, mode); } catch { /* ignore */ }
|
|
40
|
+
} finally {
|
|
41
|
+
// 确保临时文件不残留
|
|
42
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 原子写入 JSON 文件。
|
|
48
|
+
* 使用 2 空格缩进 + 尾部换行,与 OpenClaw 配置文件风格一致。
|
|
49
|
+
*
|
|
50
|
+
* @param {string} filePath - 目标文件路径
|
|
51
|
+
* @param {*} value - 要序列化的值
|
|
52
|
+
* @param {object} [opts]
|
|
53
|
+
* @param {number} [opts.mode=0o600] - 文件权限
|
|
54
|
+
* @param {number} [opts.dirMode] - 父目录权限
|
|
55
|
+
*/
|
|
56
|
+
async function atomicWriteJsonFile(filePath, value, opts) {
|
|
57
|
+
const text = JSON.stringify(value, null, 2) + '\n';
|
|
58
|
+
await atomicWriteFile(filePath, text, opts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { atomicWriteFile, atomicWriteJsonFile };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程内异步互斥锁。
|
|
3
|
+
* 用于保护 read-modify-write 等需要串行化的异步操作序列。
|
|
4
|
+
*
|
|
5
|
+
* 参照 OpenClaw createAsyncLock() 实现,基于 Promise 链的 FIFO 队列。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 创建一把互斥锁,返回 `{ withLock }` 对象。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* ```js
|
|
13
|
+
* const mutex = createMutex();
|
|
14
|
+
* const result = await mutex.withLock(async () => {
|
|
15
|
+
* const data = await readFile(path);
|
|
16
|
+
* data.count += 1;
|
|
17
|
+
* await writeFile(path, data);
|
|
18
|
+
* return data;
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @returns {{ withLock: <T>(fn: () => Promise<T>) => Promise<T> }}
|
|
23
|
+
*/
|
|
24
|
+
function createMutex() {
|
|
25
|
+
let lock = Promise.resolve();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 排队执行 fn,同一时刻只有一个 fn 在运行。
|
|
29
|
+
* fn 的返回值原样返回,fn 的异常原样抛出。
|
|
30
|
+
* @param {() => Promise<*>} fn
|
|
31
|
+
* @returns {Promise<*>}
|
|
32
|
+
*/
|
|
33
|
+
async function withLock(fn) {
|
|
34
|
+
const prev = lock;
|
|
35
|
+
let release;
|
|
36
|
+
lock = new Promise((resolve) => {
|
|
37
|
+
release = resolve;
|
|
38
|
+
});
|
|
39
|
+
await prev;
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} finally {
|
|
43
|
+
release?.();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { withLock };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { createMutex };
|