@coclaw/openclaw-coclaw 0.22.1 → 0.22.2
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 +35 -25
- package/package.json +1 -1
- package/src/chat-history-manager/manager.js +110 -2
- package/src/session-manager/manager.js +49 -14
package/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { readConfig } from './src/config.js';
|
|
|
9
9
|
import { setRuntime } from './src/runtime.js';
|
|
10
10
|
import { createSessionManager } from './src/session-manager/manager.js';
|
|
11
11
|
import { TopicManager } from './src/topic-manager/manager.js';
|
|
12
|
-
import { ChatHistoryManager } from './src/chat-history-manager/manager.js';
|
|
12
|
+
import { ChatHistoryManager, classifyChatHistorySessionKey } from './src/chat-history-manager/manager.js';
|
|
13
13
|
import { generateTitle } from './src/topic-manager/title-gen.js';
|
|
14
14
|
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
15
15
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
@@ -175,9 +175,12 @@ const plugin = {
|
|
|
175
175
|
topicManager.load('main').catch((err) => {
|
|
176
176
|
logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
|
|
177
177
|
});
|
|
178
|
-
chatHistoryManager.load('main')
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
chatHistoryManager.load('main')
|
|
179
|
+
.then(() => manager.listAllEntries('main'))
|
|
180
|
+
.then((entries) => chatHistoryManager.reconcileAll('main', entries))
|
|
181
|
+
.catch((err) => {
|
|
182
|
+
logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
|
|
183
|
+
});
|
|
181
184
|
|
|
182
185
|
// 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
|
|
183
186
|
// recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
|
|
@@ -195,37 +198,32 @@ const plugin = {
|
|
|
195
198
|
// 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
|
|
196
199
|
// agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
|
|
197
200
|
async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
|
|
198
|
-
|
|
201
|
+
// sessionKey / sessionId 非字符串时(上游 schema 异常)直接当 missing 处理,避免 split 抛 TypeError
|
|
202
|
+
// 或脏值落盘。manager 内部入口同样会校验(深度防御)。
|
|
203
|
+
if (typeof sessionKey !== 'string' || !sessionKey
|
|
204
|
+
|| typeof sessionId !== 'string' || !sessionId) {
|
|
199
205
|
// 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
|
|
200
206
|
// 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
|
|
207
|
+
// 日志兼容性:缺失(null/undefined)→ 'null';其它非字符串类型 → 'invalid'。
|
|
208
|
+
const formatField = (v) => v == null
|
|
209
|
+
? 'null'
|
|
210
|
+
: (typeof v === 'string' ? v : 'invalid');
|
|
201
211
|
logger.warn?.(
|
|
202
|
-
`[coclaw] chat history early-return: missing sessionKey/sessionId`,
|
|
212
|
+
`[coclaw] chat history early-return: missing/invalid sessionKey/sessionId`,
|
|
203
213
|
);
|
|
204
214
|
remoteLog(
|
|
205
|
-
`chat-history.missing-keys sessionKey=${sessionKey
|
|
215
|
+
`chat-history.missing-keys sessionKey=${formatField(sessionKey)} sessionId=${formatField(sessionId)}`,
|
|
206
216
|
);
|
|
207
217
|
return;
|
|
208
218
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// 改首段名),需复评本守卫。
|
|
215
|
-
const parts = sessionKey.split(':');
|
|
216
|
-
if (parts[0] === 'agent' && parts[2] === 'explicit') {
|
|
217
|
-
remoteLog(`chat-history.skip-explicit sessionKey=${sessionKey}`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
// subagent 是 OpenClaw 程序自起的子任务 run(mode=run 一次性 / mode=session 持久绑定),
|
|
221
|
-
// 形态 `agent:<id>:subagent:<uuid>`,嵌套子代理为 `agent:<id>:subagent:<uuid>:subagent:<uuid2>`。
|
|
222
|
-
// 它不是人机对话流;父 agent 的 transcript 里已含子代理最终输出(作为 user message 回流),
|
|
223
|
-
// 因此不入 chat-history。
|
|
224
|
-
// 判定从 parts[2] 起找 'subagent' 段,避免 agentId 恰好叫 'subagent' 时误伤。
|
|
225
|
-
if (parts[0] === 'agent' && parts.indexOf('subagent', 2) >= 0) {
|
|
226
|
-
remoteLog(`chat-history.skip-subagent sessionKey=${sessionKey}`);
|
|
219
|
+
// sessionKey 路由分类(explicit / subagent / cron 跳过,详见 classifyChatHistorySessionKey)。
|
|
220
|
+
// 守卫必须与启动期对账 reconcileAll 内的守卫一致——共用同一 helper 避免侧门。
|
|
221
|
+
const cls = classifyChatHistorySessionKey(sessionKey);
|
|
222
|
+
if (!cls.ok) {
|
|
223
|
+
remoteLog(`chat-history.skip-${cls.reason} sessionKey=${sessionKey}`);
|
|
227
224
|
return;
|
|
228
225
|
}
|
|
226
|
+
const parts = sessionKey.split(':');
|
|
229
227
|
let resolvedAgentId = agentId;
|
|
230
228
|
if (!resolvedAgentId) {
|
|
231
229
|
resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
|
|
@@ -251,6 +249,18 @@ const plugin = {
|
|
|
251
249
|
archivedSessionId: event?.resumedFrom,
|
|
252
250
|
});
|
|
253
251
|
});
|
|
252
|
+
// cron 顶替主会话 sid 时不走 session_start hook、不广播 sessions.changed reason=create。
|
|
253
|
+
// cron_changed action=finished 是 cron 完成的可感知通道(v2026.4.29+ 支持,见
|
|
254
|
+
// openclaw-repo/src/plugins/hook-types.ts)。main 模式 cron 不带 sessionId → 早返天然过滤;
|
|
255
|
+
// 真触发顶替的"显式 session:<sk>"/"current" 路径 event.sessionId/sessionKey 都齐。
|
|
256
|
+
api.on('cron_changed', async (event) => {
|
|
257
|
+
if (event?.action !== 'finished') return;
|
|
258
|
+
if (!event?.sessionId || !event?.sessionKey) return;
|
|
259
|
+
await handleSessionCreated({
|
|
260
|
+
sessionKey: event.sessionKey,
|
|
261
|
+
sessionId: event.sessionId,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
254
264
|
}
|
|
255
265
|
|
|
256
266
|
// bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
|
package/package.json
CHANGED
|
@@ -12,6 +12,38 @@ function emptyStore() {
|
|
|
12
12
|
return { version: 1 };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 判定 sessionKey 是否应纳入 chat-history 流水。
|
|
17
|
+
* 不纳入的三类,命中即返回对应 reason:
|
|
18
|
+
* - explicit:topic 用的伪 sessionKey,CoClaw 自管不入 chat-history
|
|
19
|
+
* - subagent:OpenClaw 程序自起的子任务(含嵌套)
|
|
20
|
+
* - cron:isolated cron 跑出的伪 sessionKey(含上游证实主 sessions.json 也会承载此类条目)
|
|
21
|
+
*
|
|
22
|
+
* 必须被所有写 chat-history 的入口(事件路径 / 启动对账等)共用,否则启动对账等绕过事件路径
|
|
23
|
+
* 的入口会把伪 sessionKey 写进 chat-history。
|
|
24
|
+
*
|
|
25
|
+
* 非字符串 / 空串 sessionKey 视为非法输入(ok=false reason=null),由 caller 自行决定怎么处理。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} sessionKey
|
|
28
|
+
* @returns {{ ok: boolean, reason: 'explicit' | 'subagent' | 'cron' | null }}
|
|
29
|
+
*/
|
|
30
|
+
export function classifyChatHistorySessionKey(sessionKey) {
|
|
31
|
+
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
32
|
+
return { ok: false, reason: null };
|
|
33
|
+
}
|
|
34
|
+
const parts = sessionKey.split(':');
|
|
35
|
+
if (parts[0] !== 'agent') return { ok: true, reason: null };
|
|
36
|
+
// 三个跳过类别都用"parts[2] 严格相等",与上游 routing 一致:
|
|
37
|
+
// - isCronSessionKey / isSubagentSessionKey 都只在 rest 起始处(即 parts[2])匹配;
|
|
38
|
+
// - cron 跑出的子代理(agent:<id>:cron:<jobId>:subagent:<uuid>)上游不视作 subagent,
|
|
39
|
+
// 由 cron 守卫挡住即可,避免与 IM per-account DM accountId="cron"/"subagent" 形态冲突
|
|
40
|
+
// (accountId 仅按 [a-z0-9_-]{1,64} 校验,"cron"/"subagent" 都是合法账户名)。
|
|
41
|
+
if (parts[2] === 'explicit') return { ok: false, reason: 'explicit' };
|
|
42
|
+
if (parts[2] === 'cron') return { ok: false, reason: 'cron' };
|
|
43
|
+
if (parts[2] === 'subagent') return { ok: false, reason: 'subagent' };
|
|
44
|
+
return { ok: true, reason: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
/**
|
|
16
48
|
* Chat History 管理器:追踪 chat(sessionKey)下的 session 流水。
|
|
17
49
|
*
|
|
@@ -131,7 +163,44 @@ export class ChatHistoryManager {
|
|
|
131
163
|
|
|
132
164
|
async __persist(agentId) {
|
|
133
165
|
const store = this.__getStore(agentId);
|
|
134
|
-
|
|
166
|
+
// 自愈守卫:list[1..](非头位)若仍有未归档项视为脏数据(cron 顶替 / 旧版本写入 / 异常 race
|
|
167
|
+
// 残留),强制补 archivedAt。放在 __persist 内是为了覆盖所有写盘路径——新增写入点自动受护。
|
|
168
|
+
this.__sanitizeAllSessionKeys(store, agentId);
|
|
169
|
+
try {
|
|
170
|
+
await this.__writeJsonFile(this.__historyFilePath(agentId), store);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// 写盘失败时清除该 agent 的内存 cache:caller 此前在 mutex 内已对 in-place list 做过
|
|
173
|
+
// unshift / splice / sanitize,这些修改不能随后被下一次"reload 命中 cache 不读盘"
|
|
174
|
+
// 的路径误持久化。删 cache 让下次操作的 __reloadFromDisk 必须重读磁盘(或 ENOENT 降级
|
|
175
|
+
// 为 empty store),保证内存与磁盘最终一致。
|
|
176
|
+
this.__cache.delete(agentId);
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 遍历 store 内每个 sessionKey 的 list,把 `list[1..]` 中 `!archivedAt` 的项强制
|
|
183
|
+
* 写上 `archivedAt = Date.now()`。每修一条同时打本地 warn + remoteLog 暴露信号。
|
|
184
|
+
* @param {object} store
|
|
185
|
+
* @param {string} agentId
|
|
186
|
+
*/
|
|
187
|
+
__sanitizeAllSessionKeys(store, agentId) {
|
|
188
|
+
if (!store || typeof store !== 'object') return;
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
for (const [sessionKey, list] of Object.entries(store)) {
|
|
191
|
+
if (!Array.isArray(list) || list.length <= 1) continue;
|
|
192
|
+
for (let i = 1; i < list.length; i++) {
|
|
193
|
+
const item = list[i];
|
|
194
|
+
if (!item || typeof item !== 'object' || item.archivedAt) continue;
|
|
195
|
+
item.archivedAt = now;
|
|
196
|
+
this.__logger.warn?.(
|
|
197
|
+
`[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
|
|
198
|
+
);
|
|
199
|
+
remoteLog(
|
|
200
|
+
`chat-history.sanitize-coerce sessionKey=${sessionKey} sid=${item.sessionId} agentId=${agentId}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
135
204
|
}
|
|
136
205
|
|
|
137
206
|
/**
|
|
@@ -153,7 +222,10 @@ export class ChatHistoryManager {
|
|
|
153
222
|
* @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
|
|
154
223
|
*/
|
|
155
224
|
async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
|
|
156
|
-
|
|
225
|
+
// 严格类型校验:上游 hook payload 异常时(非字符串 sessionId / sessionKey)静默拒绝,避免把脏值落盘。
|
|
226
|
+
if (typeof sessionKey !== 'string' || !sessionKey) return;
|
|
227
|
+
if (typeof currentSessionId !== 'string' || !currentSessionId) return;
|
|
228
|
+
if (typeof archivedSessionId !== 'string' || !archivedSessionId) archivedSessionId = undefined;
|
|
157
229
|
// 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
|
|
158
230
|
// 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
|
|
159
231
|
// 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
|
|
@@ -206,6 +278,42 @@ export class ChatHistoryManager {
|
|
|
206
278
|
});
|
|
207
279
|
}
|
|
208
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 启动期对账:把 sessions.json 当前 entries 喂进来,对每条调
|
|
283
|
+
* recordSessionTransition;现有幂等 + sanitize 自动吞重复。用于覆盖 plugin/gateway
|
|
284
|
+
* 重启窗口期 cron 顶替导致的漏归档(cron_changed hook 是主通道,但 gateway 重启不回放
|
|
285
|
+
* 已完成的 cron event,靠启动对账兜底当前 sessions.json 的 head sid)。
|
|
286
|
+
*
|
|
287
|
+
* sessions.json 里可能含 isolated cron / subagent / explicit 形态的 sessionKey
|
|
288
|
+
* 条目(上游 run-session-state.ts:57-60 证实 isolated cron 写主 sessions.json),
|
|
289
|
+
* 用 classifyChatHistorySessionKey 守卫滤掉避免污染 chat-history。
|
|
290
|
+
*
|
|
291
|
+
* 单条 entry 抛错 try/catch 隔离,不阻塞后续;caller 已在外层 .catch 兜底。
|
|
292
|
+
*
|
|
293
|
+
* @param {string} agentId
|
|
294
|
+
* @param {{ sessionKey: string, sessionId: string }[]} entries
|
|
295
|
+
*/
|
|
296
|
+
async reconcileAll(agentId, entries) {
|
|
297
|
+
if (!Array.isArray(entries)) return;
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
300
|
+
const { ok } = classifyChatHistorySessionKey(entry.sessionKey);
|
|
301
|
+
if (!ok) continue;
|
|
302
|
+
try {
|
|
303
|
+
await this.recordSessionTransition({
|
|
304
|
+
agentId,
|
|
305
|
+
sessionKey: entry.sessionKey,
|
|
306
|
+
currentSessionId: entry.sessionId,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
this.__logger.warn?.(
|
|
311
|
+
`[coclaw] chat-history reconcile entry failed sessionKey=${entry.sessionKey}: ${String(err?.message ?? err)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
209
317
|
/**
|
|
210
318
|
* 获取指定 chat 的 session 列表(原始数组:首位可能是未归档的当前活跃 session)。
|
|
211
319
|
* 每次调用从磁盘重载,确保跨模块实例一致性
|
|
@@ -37,6 +37,10 @@ async function safeReaddir(dir) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// OpenClaw ISO 时间戳:YYYY-MM-DDTHH-MM-SS[.sss]Z(与 artifacts.ts ARCHIVE_TIMESTAMP_RE 对齐:毫秒可选)
|
|
41
|
+
// 用于过滤 rsync/备份等场景带入的非法后缀(如 .jsonl.reset.<ts>.bak)
|
|
42
|
+
const ARCHIVE_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
|
|
43
|
+
|
|
40
44
|
async function safeAccess(filePath) {
|
|
41
45
|
try { await fsp.access(filePath); return true; }
|
|
42
46
|
catch (err) {
|
|
@@ -101,6 +105,26 @@ export function createSessionManager(options = {}) {
|
|
|
101
105
|
return data;
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
// 启动期对账用:直接读 sessions.json 把当前所有 sessionKey -> sessionId 摘出来。
|
|
109
|
+
// 不扫 transcript 文件 / 不做 stat,因此远比 listAll 轻量;缺/坏文件返回空数组。
|
|
110
|
+
async function listAllEntries(agentId = 'main') {
|
|
111
|
+
const idx = await readIndex(agentId);
|
|
112
|
+
// sessions.json 异常被写成数组时,Object.entries 会生成 "0"/"1" 假键,
|
|
113
|
+
// 把它们当 sessionKey 喂下游会污染。直接拒绝数组形态,同时打 warn 暴露异常。
|
|
114
|
+
if (Array.isArray(idx)) {
|
|
115
|
+
logger.warn?.(`[session-manager] sessions.json for agent=${agentId} is an array, expected object — returning empty entries`);
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const [sessionKey, item] of Object.entries(idx)) {
|
|
120
|
+
const sid = item?.sessionId;
|
|
121
|
+
if (typeof sessionKey !== 'string' || !sessionKey) continue;
|
|
122
|
+
if (typeof sid !== 'string' || !sid) continue;
|
|
123
|
+
out.push({ sessionKey, sessionId: sid });
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
104
128
|
async function listAll(params = {}) {
|
|
105
129
|
const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
|
|
106
130
|
const limit = clamp(params.limit, 1, 200, 50);
|
|
@@ -182,16 +206,24 @@ export function createSessionManager(options = {}) {
|
|
|
182
206
|
|
|
183
207
|
async function resolveTranscriptFile(agentId, sessionId) {
|
|
184
208
|
const dir = sessionsDir(agentId);
|
|
185
|
-
// live 文件优先:同一 sessionId 可能同时存在 live 和 reset
|
|
209
|
+
// live 文件优先:同一 sessionId 可能同时存在 live 和 reset/deleted 归档
|
|
186
210
|
// (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
|
|
187
211
|
const livePath = resolveTranscriptPath(sessionId, agentId);
|
|
188
212
|
if (await safeAccess(livePath)) return livePath;
|
|
189
213
|
|
|
190
|
-
|
|
214
|
+
// .reset.<ts> 与 .deleted.<ts> 都代表 session 的最终态,合并扫描按时间戳取最新
|
|
215
|
+
// 时间戳是 OpenClaw ISO YYYY-MM-DDTHH-MM-SS[.sss]Z(artifacts.ts 锁住毫秒可选),字典序 = 时间序
|
|
191
216
|
const resetPrefix = `${sessionId}.jsonl.reset.`;
|
|
192
|
-
const
|
|
217
|
+
const deletedPrefix = `${sessionId}.jsonl.deleted.`;
|
|
218
|
+
const files = await safeReaddir(dir);
|
|
219
|
+
const candidates = [];
|
|
193
220
|
for (const name of files) {
|
|
194
|
-
|
|
221
|
+
let archiveStamp;
|
|
222
|
+
if (name.startsWith(resetPrefix)) archiveStamp = name.slice(resetPrefix.length);
|
|
223
|
+
else if (name.startsWith(deletedPrefix)) archiveStamp = name.slice(deletedPrefix.length);
|
|
224
|
+
else continue;
|
|
225
|
+
// 严格 ISO 时间戳校验,过滤 .jsonl.reset.<ts>.bak 等带尾巴的备份/同步残留
|
|
226
|
+
if (!ARCHIVE_TS_RE.test(archiveStamp)) continue;
|
|
195
227
|
const full = nodePath.join(dir, name);
|
|
196
228
|
let stat;
|
|
197
229
|
try { stat = await fsp.stat(full); }
|
|
@@ -201,21 +233,21 @@ export function createSessionManager(options = {}) {
|
|
|
201
233
|
throw err;
|
|
202
234
|
}
|
|
203
235
|
/* c8 ignore stop */
|
|
204
|
-
|
|
236
|
+
candidates.push({
|
|
205
237
|
path: full,
|
|
206
|
-
archiveStamp
|
|
238
|
+
archiveStamp,
|
|
207
239
|
updatedAt: stat.mtimeMs,
|
|
208
240
|
});
|
|
209
241
|
}
|
|
210
|
-
|
|
242
|
+
candidates.sort((a, b) => {
|
|
211
243
|
if (a.archiveStamp !== b.archiveStamp) {
|
|
212
244
|
return b.archiveStamp.localeCompare(a.archiveStamp);
|
|
213
245
|
}
|
|
214
|
-
/* c8 ignore next --
|
|
246
|
+
/* c8 ignore next -- 同 sessionId 同 archiveStamp 的归档实测 0 case */
|
|
215
247
|
return b.updatedAt - a.updatedAt;
|
|
216
248
|
});
|
|
217
|
-
if (
|
|
218
|
-
return
|
|
249
|
+
if (candidates.length > 0) {
|
|
250
|
+
return candidates[0].path;
|
|
219
251
|
}
|
|
220
252
|
return null;
|
|
221
253
|
}
|
|
@@ -272,6 +304,7 @@ export function createSessionManager(options = {}) {
|
|
|
272
304
|
/**
|
|
273
305
|
* 按 sessionId 获取消息,返回完整 JSONL 行级结构。
|
|
274
306
|
* 只返回 type==="message" 且有合法 message.role 的行。
|
|
307
|
+
* limit 语义:不传/null/非 number/NaN/Infinity/<1 → 返回全部;>=1 的有限 number → 取最后 Math.trunc(limit) 条。无默认/最大值。
|
|
275
308
|
* @param {{ sessionId: string, agentId?: string, limit?: number }} params
|
|
276
309
|
* @returns {Promise<{ messages: object[] }>}
|
|
277
310
|
*/
|
|
@@ -279,7 +312,10 @@ export function createSessionManager(options = {}) {
|
|
|
279
312
|
const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
|
|
280
313
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
|
|
281
314
|
if (!sessionId) throw new Error('sessionId required');
|
|
282
|
-
|
|
315
|
+
// limit 类型严格:只接受 number 且 >= 1。string/bool/array 走非 number 分支被拒,
|
|
316
|
+
// 0 / 负数 / NaN / Infinity / (0,1) 区间也都视为"不限"——(0,1) 不视为不限的话 Math.trunc 后会变 0、slice(-0) 退化为全部
|
|
317
|
+
const useLimit = typeof params.limit === 'number' && Number.isFinite(params.limit) && params.limit >= 1;
|
|
318
|
+
const limitNum = useLimit ? Math.trunc(params.limit) : 0;
|
|
283
319
|
const file = await resolveTranscriptFile(agentId, sessionId);
|
|
284
320
|
if (!file) {
|
|
285
321
|
return { messages: [] };
|
|
@@ -300,10 +336,9 @@ export function createSessionManager(options = {}) {
|
|
|
300
336
|
logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
|
|
301
337
|
}
|
|
302
338
|
}
|
|
303
|
-
|
|
304
|
-
const sliced = messages.length > limit ? messages.slice(-limit) : messages;
|
|
339
|
+
const sliced = (useLimit && messages.length > limitNum) ? messages.slice(-limitNum) : messages;
|
|
305
340
|
return { messages: sliced };
|
|
306
341
|
}
|
|
307
342
|
|
|
308
|
-
return { listAll, get, getById };
|
|
343
|
+
return { listAll, listAllEntries, get, getById };
|
|
309
344
|
}
|