@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 +68 -3
- package/package.json +1 -1
- package/src/chat-history-manager/manager.js +171 -0
- package/src/realtime-bridge.js +6 -1
- package/src/session-manager/manager.js +50 -3
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
|
@@ -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
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 */
|