koishi-plugin-satori-ai 2.0.0 → 2.0.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/lib/index.js CHANGED
@@ -170,6 +170,10 @@ __name(extendDatabase, "extendDatabase");
170
170
  var fs = __toESM(require("fs"));
171
171
  var path = __toESM(require("path"));
172
172
  var logger = new import_koishi.Logger("satori-utils");
173
+ function escapeRegExp(str) {
174
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
175
+ }
176
+ __name(escapeRegExp, "escapeRegExp");
173
177
  function parseTimeToMinutes(timeStr) {
174
178
  const [hours, minutes] = timeStr.split(":").map(Number);
175
179
  return hours * 60 + (minutes || 0);
@@ -252,16 +256,36 @@ function isErrorWithMessage(error) {
252
256
  return typeof error === "object" && error !== null && "message" in error;
253
257
  }
254
258
  __name(isErrorWithMessage, "isErrorWithMessage");
255
- function processPrompt(prompt) {
259
+ function processPrompt(prompt, botNames = [], botIds = []) {
256
260
  if (!prompt)
257
261
  return "";
258
262
  if (prompt.includes(":poke"))
259
263
  return "戳戳";
264
+ const names = botNames.filter(Boolean);
265
+ const ids = botIds.filter(Boolean);
266
+ prompt = prompt.replace(/<at\b[^>]*?name="([^\"]*)"[^>]*>/gi, (tag, name) => {
267
+ const id = /\bid="([^"]*)"/i.exec(tag)?.[1];
268
+ if (ids.includes(id) || names.includes(name))
269
+ return "";
270
+ return name;
271
+ });
272
+ prompt = prompt.replace(/<at\b[^>]*\/?>/gi, "");
260
273
  prompt = prompt.replace(/<[^>]*?avatar[^>]*>/g, "。回复:");
261
274
  prompt = prompt.replace(/<[^>]*?img[^>]*>/g, "[图片]");
262
275
  prompt = prompt.replace(/<[^>]*?name="([^\"]*)"[^>]*>/g, (_, name) => `${name}`);
276
+ if (names.length) {
277
+ const pattern = [...new Set(names.map((name) => name.replace(/[\s,,.。!!??~~::;;、]+$/, "")))].filter(Boolean).sort((a, b) => b.length - a.length).map(escapeRegExp).join("|");
278
+ if (pattern) {
279
+ const stripped = prompt.replace(
280
+ new RegExp(`^\\s*(?:${pattern})(?:\\s+|[,,。.!!??~~::;;、]+|$)`),
281
+ ""
282
+ );
283
+ if (stripped.trim())
284
+ prompt = stripped;
285
+ }
286
+ }
263
287
  prompt = prompt.replace(/\*\*/g, "");
264
- if (!prompt)
288
+ if (!prompt.trim())
265
289
  return "**";
266
290
  return prompt.trim();
267
291
  }
@@ -1205,13 +1229,10 @@ var TOPIC_MAX_TURNS = 20;
1205
1229
  var TOPIC_MAX_AGE_HOURS = 24;
1206
1230
  var MAX_ARCHIVE_DIALOGUES = 200;
1207
1231
  var MAX_CHILDREN_PER_SUMMARY = 100;
1208
- var LEVEL_DAYS = {
1209
- day: 1,
1210
- week: 7,
1211
- month: 28,
1212
- season: 90,
1213
- year: 365
1214
- };
1232
+ var MAX_LEVEL_NODES_PER_TICK = 10;
1233
+ var DEFAULT_MAX_CALLS_PER_TICK = 8;
1234
+ var HOUR_MS = 36e5;
1235
+ var DAY_MS = 864e5;
1215
1236
  var LEVEL_ORDER = ["day", "week", "month", "season", "year"];
1216
1237
  var CHILD_OF = {
1217
1238
  day: "topic",
@@ -1220,12 +1241,62 @@ var CHILD_OF = {
1220
1241
  season: "month",
1221
1242
  year: "season"
1222
1243
  };
1244
+ var DIALOGUE_KINDS = ["turn", "legacy"];
1223
1245
  function formatTime(date, timezoneOffsetHours) {
1224
1246
  const shifted = new Date(new Date(date).getTime() + timezoneOffsetHours * 36e5);
1225
1247
  const pad = /* @__PURE__ */ __name((value) => String(value).padStart(2, "0"), "pad");
1226
1248
  return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`;
1227
1249
  }
1228
1250
  __name(formatTime, "formatTime");
1251
+ function dayStart(date, timezoneOffset, boundaryHour) {
1252
+ return lastBoundary(date, timezoneOffset, boundaryHour);
1253
+ }
1254
+ __name(dayStart, "dayStart");
1255
+ function nextDayStart(date, timezoneOffset, boundaryHour) {
1256
+ return new Date(lastBoundary(date, timezoneOffset, boundaryHour).getTime() + DAY_MS);
1257
+ }
1258
+ __name(nextDayStart, "nextDayStart");
1259
+ function periodStart(level, date, timezoneOffset, boundaryHour) {
1260
+ const start = lastBoundary(date, timezoneOffset, boundaryHour);
1261
+ if (level === "day")
1262
+ return start;
1263
+ const offsetMs = timezoneOffset * HOUR_MS;
1264
+ const local = new Date(start.getTime() + offsetMs);
1265
+ const year = local.getUTCFullYear();
1266
+ const month = local.getUTCMonth();
1267
+ if (level === "week") {
1268
+ const back = (local.getUTCDay() + 6) % 7;
1269
+ return new Date(start.getTime() - back * DAY_MS);
1270
+ }
1271
+ if (level === "month") {
1272
+ return new Date(Date.UTC(year, month, 1, boundaryHour) - offsetMs);
1273
+ }
1274
+ if (level === "season") {
1275
+ return new Date(Date.UTC(year, Math.floor(month / 3) * 3, 1, boundaryHour) - offsetMs);
1276
+ }
1277
+ return new Date(Date.UTC(year, 0, 1, boundaryHour) - offsetMs);
1278
+ }
1279
+ __name(periodStart, "periodStart");
1280
+ function periodEnd(level, start, timezoneOffset, boundaryHour) {
1281
+ if (level === "day")
1282
+ return new Date(start.getTime() + DAY_MS);
1283
+ if (level === "week")
1284
+ return new Date(start.getTime() + 7 * DAY_MS);
1285
+ const offsetMs = timezoneOffset * HOUR_MS;
1286
+ const local = new Date(start.getTime() + offsetMs);
1287
+ let year = local.getUTCFullYear();
1288
+ let month = local.getUTCMonth();
1289
+ if (level === "month")
1290
+ month += 1;
1291
+ else if (level === "season")
1292
+ month += 3;
1293
+ else
1294
+ year += 1;
1295
+ year += Math.floor(month / 12);
1296
+ month = (month % 12 + 12) % 12;
1297
+ return new Date(Date.UTC(year, month, 1, boundaryHour) - offsetMs);
1298
+ }
1299
+ __name(periodEnd, "periodEnd");
1229
1300
  var Summarizer = class {
1230
1301
  constructor(ctx, repo, api, profile, config) {
1231
1302
  this.ctx = ctx;
@@ -1239,6 +1310,12 @@ var Summarizer = class {
1239
1310
  }
1240
1311
  /** 同一 scope 同一时刻只跑一个汇总任务,避免心跳与话题切换并发写入 */
1241
1312
  busy = /* @__PURE__ */ new Set();
1313
+ get tz() {
1314
+ return this.config.timezoneOffset;
1315
+ }
1316
+ get boundaryHour() {
1317
+ return this.config.dayBoundaryHour;
1318
+ }
1242
1319
  // ---------- 话题 ----------
1243
1320
  /**
1244
1321
  * 每轮对话后调用:必要时归档当前话题。
@@ -1251,7 +1328,7 @@ var Summarizer = class {
1251
1328
  const user = await this.lookupUser(scopeId);
1252
1329
  const meta = await this.repo.ensureMeta(scopeId);
1253
1330
  const dialogues = await this.repo.getDialoguesAfter(scopeId, meta.topicStart, {
1254
- kinds: ["turn", "legacy"],
1331
+ kinds: DIALOGUE_KINDS,
1255
1332
  // 只需要判断是否达到阈值,不把迁移后的全部历史读进内存
1256
1333
  limit: TOPIC_MAX_TURNS
1257
1334
  });
@@ -1261,7 +1338,7 @@ var Summarizer = class {
1261
1338
  if (!shouldSwitch) {
1262
1339
  const text = this.formatDialogues(dialogues);
1263
1340
  const result = await this.api.auxiliaryChat(
1264
- buildTopicSwitchPrompt(text, formatTime(meta.topicStart, this.config.timezoneOffset)),
1341
+ buildTopicSwitchPrompt(text, formatTime(meta.topicStart, this.tz)),
1265
1342
  user
1266
1343
  );
1267
1344
  if (result.error)
@@ -1273,123 +1350,255 @@ var Summarizer = class {
1273
1350
  await this.archiveTopicLocked(scopeId, user);
1274
1351
  });
1275
1352
  }
1276
- /** 兜底:话题持续时间过长时强制归档一次,否则金字塔会断在 topic 级 */
1277
- async archiveStaleTopic(scopeId, user) {
1278
- const meta = await this.repo.ensureMeta(scopeId);
1279
- const ageHours = (Date.now() - new Date(meta.topicStart).getTime()) / 36e5;
1280
- if (ageHours < TOPIC_MAX_AGE_HOURS)
1281
- return;
1282
- const dialogues = await this.repo.getDialoguesAfter(scopeId, meta.topicStart, {
1283
- kinds: ["turn", "legacy"],
1284
- limit: 1
1285
- });
1286
- if (!dialogues.length)
1287
- return;
1288
- await this.archiveTopicLocked(scopeId, user);
1289
- }
1290
- /** 把 [topicStart, 末尾] 的对话归档为一条 topic 记忆。调用方需持有 busy 锁。 */
1353
+ /**
1354
+ * 把游标所在自然日内、尚未归档的对话归档为一条 topic 记忆。
1355
+ * 超过 MAX_ARCHIVE_DIALOGUES 时只取前 200 条(同一天拆成多块)。
1356
+ * 返回是否真的发起并完成了归档。
1357
+ */
1291
1358
  async archiveTopicLocked(scopeId, user) {
1292
1359
  const meta = await this.repo.ensureMeta(scopeId);
1293
- const dialogues = await this.repo.getDialoguesAfter(scopeId, meta.topicStart, {
1294
- kinds: ["turn", "legacy"],
1295
- limit: MAX_ARCHIVE_DIALOGUES
1360
+ const cursor = new Date(meta.topicStart);
1361
+ const dayEnd = nextDayStart(cursor, this.tz, this.boundaryHour);
1362
+ const dialogues = await this.repo.getDialoguesAfter(scopeId, cursor, {
1363
+ kinds: DIALOGUE_KINDS,
1364
+ limit: MAX_ARCHIVE_DIALOGUES,
1365
+ before: dayEnd
1296
1366
  });
1297
1367
  if (!dialogues.length)
1298
- return;
1368
+ return false;
1299
1369
  const text = this.formatDialogues(dialogues);
1300
1370
  if (this.config.dryRun) {
1301
- logger4.info(`[dry-run] topic 归档 scope=${scopeId} 对话=${dialogues.length} 字符=${text.length}`);
1302
- return;
1371
+ logger4.info(`[dry-run] topic 归档 scope=${scopeId} 对话=${dialogues.length} 字符=${text.length} 日=${formatTime(cursor, this.tz)}`);
1372
+ return false;
1303
1373
  }
1304
1374
  const result = await this.api.auxiliaryChat(buildTopicArchivePrompt(text), user);
1305
1375
  if (result.error) {
1306
1376
  logger4.warn(`topic 归档失败 scope=${scopeId}: ${result.content}`);
1307
- return;
1377
+ return false;
1308
1378
  }
1309
1379
  const parsed = parseLlmJson(result.content);
1310
1380
  const summary = parsed?.summary?.trim();
1311
1381
  if (!summary) {
1312
1382
  logger4.warn(`topic 归档解析失败 scope=${scopeId}: ${result.content?.slice(0, 200)}`);
1313
- return;
1383
+ return false;
1314
1384
  }
1315
- const periodStart = dialogues[0].createdAt;
1385
+ const periodStart2 = dialogues[0].createdAt;
1316
1386
  const last = dialogues[dialogues.length - 1];
1387
+ let nextCursor = new Date(last.createdAt);
1388
+ if (dayEnd.getTime() <= Date.now()) {
1389
+ const remaining2 = await this.repo.countDialoguesInRange(
1390
+ scopeId,
1391
+ nextCursor,
1392
+ dayEnd,
1393
+ DIALOGUE_KINDS
1394
+ );
1395
+ if (!remaining2)
1396
+ nextCursor = dayEnd;
1397
+ }
1317
1398
  await this.repo.commitTopicArchive({
1318
1399
  scopeId,
1319
1400
  granularity: "topic",
1320
1401
  topic: (parsed.topic || "").trim(),
1321
1402
  content: summary,
1322
1403
  userState: (parsed.user_state || "").trim(),
1323
- periodStart,
1404
+ periodStart: periodStart2,
1324
1405
  periodEnd: last.createdAt,
1325
1406
  sourceCount: dialogues.length,
1326
1407
  createdAt: /* @__PURE__ */ new Date()
1327
- }, scopeId, new Date(last.createdAt));
1328
- const remaining = await this.repo.countDialoguesAfter(scopeId, new Date(last.createdAt));
1329
- logger4.info(`topic 归档完成 scope=${scopeId} 对话=${dialogues.length}${remaining ? ` 剩余${remaining}条待处理` : ""}`);
1408
+ }, scopeId, nextCursor);
1409
+ const remaining = await this.repo.countDialoguesAfter(scopeId, nextCursor);
1410
+ logger4.info(`topic 归档完成 scope=${scopeId} 对话=${dialogues.length} 日=${formatTime(dayStart(cursor, this.tz, this.boundaryHour), this.tz)}` + (remaining ? ` 剩余${remaining}条待处理` : ""));
1411
+ return true;
1412
+ }
1413
+ /**
1414
+ * 按自然日消费积压:每次归档一个日块,直到预算用完。
1415
+ * - 全是空白的日期用一次查询跨过去,不逐天循环;
1416
+ * - 已关闭的日子必须归档干净(哪怕只剩 1 条),否则日级永远等不到闭合;
1417
+ * - 进行中的当天沿用旧语义:话题超过 24 小时才兜底归档。
1418
+ */
1419
+ async drainTopics(scopeId, user, budget) {
1420
+ if (this.config.dryRun) {
1421
+ const meta = await this.repo.ensureMeta(scopeId);
1422
+ const [next] = await this.repo.getDialoguesAfter(scopeId, meta.topicStart, {
1423
+ kinds: DIALOGUE_KINDS,
1424
+ limit: 1
1425
+ });
1426
+ if (next)
1427
+ logger4.info(`[dry-run] 将归档 scope=${scopeId} 起点=${formatTime(next.createdAt, this.tz)}`);
1428
+ return;
1429
+ }
1430
+ while (budget.calls > 0) {
1431
+ const meta = await this.repo.ensureMeta(scopeId);
1432
+ const cursor = new Date(meta.topicStart);
1433
+ const dayEnd = nextDayStart(cursor, this.tz, this.boundaryHour);
1434
+ const [next] = await this.repo.getDialoguesAfter(scopeId, cursor, {
1435
+ kinds: DIALOGUE_KINDS,
1436
+ limit: 1
1437
+ });
1438
+ if (!next) {
1439
+ const todayStart = dayStart(/* @__PURE__ */ new Date(), this.tz, this.boundaryHour);
1440
+ if (todayStart.getTime() > cursor.getTime()) {
1441
+ await this.repo.setTopicStart(scopeId, todayStart);
1442
+ }
1443
+ return;
1444
+ }
1445
+ const nextDay = dayStart(new Date(next.createdAt), this.tz, this.boundaryHour);
1446
+ if (nextDay.getTime() > cursor.getTime()) {
1447
+ await this.repo.setTopicStart(scopeId, nextDay);
1448
+ continue;
1449
+ }
1450
+ if (dayEnd.getTime() > Date.now()) {
1451
+ const ageHours = (Date.now() - cursor.getTime()) / HOUR_MS;
1452
+ if (ageHours < TOPIC_MAX_AGE_HOURS)
1453
+ return;
1454
+ }
1455
+ const archived = await this.archiveTopicLocked(scopeId, user);
1456
+ if (!archived)
1457
+ return;
1458
+ budget.calls -= 1;
1459
+ }
1330
1460
  }
1331
1461
  // ---------- 分层汇总 ----------
1332
1462
  async summarizeScope(scopeId) {
1333
1463
  return this.runExclusive(scopeId, async () => {
1334
1464
  const user = await this.lookupUser(scopeId);
1335
1465
  await this.repo.touchMeta(scopeId);
1336
- await this.archiveStaleTopic(scopeId, user);
1337
- for (const level of LEVEL_ORDER) {
1338
- await this.summarizeLevel(scopeId, level, user);
1339
- }
1466
+ const total = Math.max(2, this.config.maxCallsPerTick ?? DEFAULT_MAX_CALLS_PER_TICK);
1467
+ const archiveQuota = Math.max(1, Math.floor(total / 2));
1468
+ const archiveBudget = { calls: archiveQuota };
1469
+ await this.drainTopics(scopeId, user, archiveBudget);
1470
+ const levelBudget = {
1471
+ calls: Math.max(1, total - (archiveQuota - archiveBudget.calls))
1472
+ };
1473
+ await this.buildLevels(scopeId, user, levelBudget);
1340
1474
  });
1341
1475
  }
1342
- async summarizeLevel(scopeId, level, user) {
1343
- const now = /* @__PURE__ */ new Date();
1344
- const cutoff = level === "day" ? lastBoundary(now, this.config.timezoneOffset, this.config.dayBoundaryHour) : new Date(now.getTime() - LEVEL_DAYS[level] * 864e5);
1345
- const parent = await this.repo.getLatestMemory(scopeId, level);
1346
- const from = parent ? new Date(parent.periodEnd) : null;
1347
- const children = (await this.repo.getMemoriesInRange(
1348
- scopeId,
1349
- CHILD_OF[level],
1350
- from,
1351
- cutoff
1352
- )).slice(0, MAX_CHILDREN_PER_SUMMARY);
1353
- if (!children.length)
1354
- return;
1355
- const text = this.formatChildren(children);
1356
- if (this.config.dryRun) {
1357
- logger4.info(`[dry-run] ${level} 汇总 scope=${scopeId} 子记忆=${children.length} 字符=${text.length} 区间=${formatTime(children[0].periodStart, this.config.timezoneOffset)}~${formatTime(children[children.length - 1].periodEnd, this.config.timezoneOffset)}`);
1358
- return;
1359
- }
1360
- const result = await this.api.auxiliaryChat(buildSummaryPrompt(level, text), user);
1361
- if (result.error) {
1362
- logger4.warn(`${level} 汇总失败 scope=${scopeId}: ${result.content}`);
1363
- return;
1364
- }
1365
- const parsed = parseLlmJson(result.content);
1366
- const summary = parsed?.summary?.trim();
1367
- if (!summary) {
1368
- logger4.warn(`${level} 汇总解析失败 scope=${scopeId}: ${result.content?.slice(0, 200)}`);
1369
- return;
1370
- }
1371
- const first = children[0];
1372
- const last = children[children.length - 1];
1373
- await this.repo.createMemory({
1374
- scopeId,
1375
- granularity: level,
1376
- topic: "",
1377
- content: summary,
1378
- userState: (parsed.user_state || "").trim(),
1379
- periodStart: new Date(first.periodStart),
1380
- periodEnd: new Date(last.periodEnd),
1381
- sourceCount: children.length,
1382
- createdAt: /* @__PURE__ */ new Date()
1383
- });
1384
- logger4.info(`${level} 汇总完成 scope=${scopeId} 子记忆=${children.length}`);
1385
- if (level === "day") {
1386
- await this.profile.summarize(scopeId, summary, children, user);
1476
+ /**
1477
+ * 自下而上补齐已闭合的周期,每个周期最多一条节点。
1478
+ *
1479
+ * 起算点来自该层最新节点的 periodEnd(没有则取最早子节点所在周期),
1480
+ * 因此不会重复生成同一周期;空周期没有子节点,会被自然跳过。
1481
+ * 周期末尾晚于归档游标时说明周期内的对话还没归档完,立即停止该层。
1482
+ */
1483
+ async buildLevels(scopeId, user, budget) {
1484
+ const meta = await this.repo.ensureMeta(scopeId);
1485
+ const frontier = new Date(meta.topicStart);
1486
+ for (const level of LEVEL_ORDER) {
1487
+ let nodes = 0;
1488
+ let reached = false;
1489
+ while (nodes < MAX_LEVEL_NODES_PER_TICK) {
1490
+ const parent = await this.repo.getLatestMemory(scopeId, level);
1491
+ const from = parent ? new Date(parent.periodEnd) : null;
1492
+ const [child] = await this.repo.getMemoriesInRange(
1493
+ scopeId,
1494
+ CHILD_OF[level],
1495
+ from,
1496
+ frontier,
1497
+ 1
1498
+ );
1499
+ if (!child) {
1500
+ reached = true;
1501
+ break;
1502
+ }
1503
+ const start = periodStart(level, new Date(new Date(child.periodEnd).getTime() - 1), this.tz, this.boundaryHour);
1504
+ const end = periodEnd(level, start, this.tz, this.boundaryHour);
1505
+ if (end.getTime() > frontier.getTime()) {
1506
+ reached = true;
1507
+ break;
1508
+ }
1509
+ const children = await this.repo.getMemoriesInRange(
1510
+ scopeId,
1511
+ CHILD_OF[level],
1512
+ start,
1513
+ end
1514
+ );
1515
+ if (!children.length)
1516
+ break;
1517
+ if (level === "day") {
1518
+ const stray = children.find((row) => dayStart(new Date(row.periodStart), this.tz, this.boundaryHour).getTime() !== dayStart(new Date(new Date(row.periodEnd).getTime() - 1), this.tz, this.boundaryHour).getTime());
1519
+ if (stray) {
1520
+ logger4.warn(`day 汇总暂停:存在跨日 topic 记忆 scope=${scopeId} 记忆区间=${formatTime(stray.periodStart, this.tz)} ~ ${formatTime(stray.periodEnd, this.tz)}`);
1521
+ break;
1522
+ }
1523
+ }
1524
+ const range = `${formatTime(start, this.tz)} ~ ${formatTime(end, this.tz)}`;
1525
+ const single = children.length === 1;
1526
+ if (single) {
1527
+ if (this.config.dryRun) {
1528
+ logger4.info(`[dry-run] ${level} 晋升 scope=${scopeId} 周期=${range} 子记忆=1`);
1529
+ return;
1530
+ }
1531
+ if (level === "day" && this.profile.isEnabled()) {
1532
+ if (budget.calls <= 0)
1533
+ return;
1534
+ budget.calls -= 1;
1535
+ await this.profile.summarize(scopeId, children[0].content, children, user);
1536
+ }
1537
+ await this.repo.createMemory({
1538
+ scopeId,
1539
+ granularity: level,
1540
+ topic: "",
1541
+ content: children[0].content,
1542
+ userState: children[0].userState || "",
1543
+ periodStart: start,
1544
+ periodEnd: end,
1545
+ sourceCount: 1,
1546
+ createdAt: /* @__PURE__ */ new Date()
1547
+ });
1548
+ logger4.info(`${level} 晋升 scope=${scopeId} 周期=${range}`);
1549
+ nodes += 1;
1550
+ continue;
1551
+ }
1552
+ const need = 1 + (level === "day" && this.profile.isEnabled() ? 1 : 0);
1553
+ if (budget.calls < need)
1554
+ return;
1555
+ const input = children.slice(0, MAX_CHILDREN_PER_SUMMARY);
1556
+ if (children.length > MAX_CHILDREN_PER_SUMMARY) {
1557
+ logger4.warn(`${level} 子记忆过多,只汇总前 ${MAX_CHILDREN_PER_SUMMARY} 条 scope=${scopeId} 实际=${children.length}`);
1558
+ }
1559
+ const text = this.formatChildren(input);
1560
+ if (this.config.dryRun) {
1561
+ logger4.info(`[dry-run] ${level} 汇总 scope=${scopeId} 周期=${range} 子记忆=${children.length}`);
1562
+ return;
1563
+ }
1564
+ const result = await this.api.auxiliaryChat(buildSummaryPrompt(level, text), user);
1565
+ budget.calls -= 1;
1566
+ if (result.error) {
1567
+ logger4.warn(`${level} 汇总失败 scope=${scopeId}: ${result.content}`);
1568
+ return;
1569
+ }
1570
+ const parsed = parseLlmJson(result.content);
1571
+ const summary = parsed?.summary?.trim();
1572
+ if (!summary) {
1573
+ logger4.warn(`${level} 汇总解析失败 scope=${scopeId}: ${result.content?.slice(0, 200)}`);
1574
+ return;
1575
+ }
1576
+ await this.repo.createMemory({
1577
+ scopeId,
1578
+ granularity: level,
1579
+ topic: "",
1580
+ content: summary,
1581
+ userState: (parsed.user_state || "").trim(),
1582
+ periodStart: start,
1583
+ periodEnd: end,
1584
+ sourceCount: children.length,
1585
+ createdAt: /* @__PURE__ */ new Date()
1586
+ });
1587
+ logger4.info(`${level} 汇总完成 scope=${scopeId} 周期=${range} 子记忆=${children.length}`);
1588
+ nodes += 1;
1589
+ if (level === "day" && this.profile.isEnabled()) {
1590
+ budget.calls -= 1;
1591
+ await this.profile.summarize(scopeId, summary, children, user);
1592
+ }
1593
+ }
1594
+ if (!reached)
1595
+ return;
1387
1596
  }
1388
1597
  }
1389
1598
  // ---------- 工具 ----------
1390
1599
  formatDialogues(rows) {
1391
1600
  return rows.map((row) => {
1392
- const time = formatTime(row.createdAt, this.config.timezoneOffset);
1601
+ const time = formatTime(row.createdAt, this.tz);
1393
1602
  const user = row.username || "用户";
1394
1603
  const ai = row.aiContent && row.aiContent !== "…" ? `
1395
1604
  AI:${row.aiContent}` : "";
@@ -1398,7 +1607,7 @@ AI:${row.aiContent}` : "";
1398
1607
  }
1399
1608
  formatChildren(children) {
1400
1609
  return children.map((child) => {
1401
- const range = `${formatTime(child.periodStart, this.config.timezoneOffset)} ~ ${formatTime(child.periodEnd, this.config.timezoneOffset)}`;
1610
+ const range = `${formatTime(child.periodStart, this.tz)} ~ ${formatTime(child.periodEnd, this.tz)}`;
1402
1611
  const topic = child.topic ? `(${child.topic})` : "";
1403
1612
  const state = child.userState ? `
1404
1613
  用户状态:${child.userState}` : "";
@@ -2024,6 +2233,9 @@ var ProfileManager = class {
2024
2233
  static {
2025
2234
  __name(this, "ProfileManager");
2026
2235
  }
2236
+ isEnabled() {
2237
+ return this.config.enabled;
2238
+ }
2027
2239
  /** 日总结完成后调用;持票用户走自己的 key(与 chat 一致) */
2028
2240
  async summarize(scopeId, summary, memories, user = null) {
2029
2241
  if (!this.config.enabled)
@@ -2134,7 +2346,10 @@ var MemoryRepository = class {
2134
2346
  });
2135
2347
  }
2136
2348
  async getDialoguesAfter(scopeId, after, options) {
2137
- const query = { scopeId, createdAt: { $gt: after } };
2349
+ const createdAt = { $gt: after };
2350
+ if (options?.before)
2351
+ createdAt.$lt = options.before;
2352
+ const query = { scopeId, createdAt };
2138
2353
  if (options?.kinds?.length)
2139
2354
  query.kind = { $in: options.kinds };
2140
2355
  return this.ctx.database.get("sat_dialogue", query, {
@@ -2149,6 +2364,14 @@ var MemoryRepository = class {
2149
2364
  const rows = await this.ctx.database.get("sat_dialogue", query, { fields: ["id"] });
2150
2365
  return rows.length;
2151
2366
  }
2367
+ /** after < createdAt < before 的对话数,用于判断一个已关闭的日块是否归档干净 */
2368
+ async countDialoguesInRange(scopeId, after, before, kinds) {
2369
+ const query = { scopeId, createdAt: { $gt: after, $lt: before } };
2370
+ if (kinds?.length)
2371
+ query.kind = { $in: kinds };
2372
+ const rows = await this.ctx.database.get("sat_dialogue", query, { fields: ["id"] });
2373
+ return rows.length;
2374
+ }
2152
2375
  async countDialogues(scopeId) {
2153
2376
  const rows = await this.ctx.database.get("sat_dialogue", { scopeId }, { fields: ["id"] });
2154
2377
  return rows.length;
@@ -2250,12 +2473,13 @@ var MemoryRepository = class {
2250
2473
  return rows[0]?.periodStart || null;
2251
2474
  }
2252
2475
  /** 区间查询:from < periodEnd <= to,走真正的 SQL 条件而不是 JS 侧过滤 */
2253
- async getMemoriesInRange(scopeId, granularity, from, to) {
2254
- const periodEnd = { $lte: to };
2476
+ async getMemoriesInRange(scopeId, granularity, from, to, limit) {
2477
+ const periodEnd2 = { $lte: to };
2255
2478
  if (from)
2256
- periodEnd.$gt = from;
2257
- return this.ctx.database.get("sat_memory", { scopeId, granularity, periodEnd }, {
2258
- sort: { periodEnd: "asc" }
2479
+ periodEnd2.$gt = from;
2480
+ return this.ctx.database.get("sat_memory", { scopeId, granularity, periodEnd: periodEnd2 }, {
2481
+ sort: { periodEnd: "asc" },
2482
+ limit
2259
2483
  });
2260
2484
  }
2261
2485
  async createMemory(row) {
@@ -2468,6 +2692,7 @@ var MemoryService = class {
2468
2692
  topicSwitchCheck: config.topic_switch_check,
2469
2693
  timezoneOffset: config.memory_timezone_offset,
2470
2694
  dayBoundaryHour: config.memory_day_boundary_hour,
2695
+ maxCallsPerTick: config.summary_max_calls_per_tick,
2471
2696
  resolveUser
2472
2697
  });
2473
2698
  this.contextBuilder = new ContextBuilder(this.repo, this.shortTerm, this.profile, {
@@ -3013,6 +3238,7 @@ var Sat = class extends import_koishi10.Service {
3013
3238
  topic_switch_check: import_koishi10.Schema.boolean().default(true).description("是否用辅助模型判断话题切换"),
3014
3239
  summary_heartbeat_minutes: import_koishi10.Schema.number().default(10).description("后台汇总心跳间隔(分钟)"),
3015
3240
  summary_max_scopes_per_tick: import_koishi10.Schema.number().default(3).description("每次心跳最多处理的用户/群数量"),
3241
+ summary_max_calls_per_tick: import_koishi10.Schema.number().default(8).description("每个用户/群每次心跳最多发起的辅助模型调用数(归档与分层汇总各占一半预算;历史积压时调大可加快补齐)"),
3016
3242
  memory_active_window_days: import_koishi10.Schema.number().default(7).description("只对最近多少天内活跃过的用户/群做汇总"),
3017
3243
  memory_retention_days: import_koishi10.Schema.number().default(180).description("原始对话保留天数"),
3018
3244
  memory_recall_topN: import_koishi10.Schema.number().default(5).description("关键词召回的历史记忆条数"),
@@ -5382,6 +5608,7 @@ var SAT = class extends Sat {
5382
5608
  summary_heartbeat_minutes: this.config.summary_heartbeat_minutes,
5383
5609
  summary_dry_run: this.config.summary_dry_run,
5384
5610
  summary_max_scopes_per_tick: this.config.summary_max_scopes_per_tick,
5611
+ summary_max_calls_per_tick: this.config.summary_max_calls_per_tick,
5385
5612
  memory_active_window_days: this.config.memory_active_window_days,
5386
5613
  memory_retention_days: this.config.memory_retention_days,
5387
5614
  memory_context_max_chars: this.config.memory_context_max_chars,
@@ -5627,7 +5854,7 @@ var SAT = class extends Sat {
5627
5854
  }
5628
5855
  // 处理输入
5629
5856
  async processInput(session, prompt) {
5630
- const processedPrompt = processPrompt(prompt);
5857
+ const processedPrompt = processPrompt(prompt, this.config.nick_name_list, [session.selfId]);
5631
5858
  let censored = processedPrompt;
5632
5859
  if (this.ctx.censor) {
5633
5860
  censored = await this.ctx.censor.transform(processedPrompt, session);
@@ -6010,7 +6237,7 @@ ${useNoReasoner ? promptForNoReasoner : promptForReasoner}
6010
6237
  }
6011
6238
  // 频道记忆中间件转接
6012
6239
  async handleChannelMemoryManager(session) {
6013
- const processedPrompt = processPrompt(session.content);
6240
+ const processedPrompt = processPrompt(session.content, this.config.nick_name_list, [session.selfId]);
6014
6241
  if (this.performPreChecks(session, processedPrompt))
6015
6242
  return null;
6016
6243
  let censored = processedPrompt;
@@ -10,6 +10,7 @@ export interface MemoryServiceConfig extends MemoryConfig {
10
10
  summary_heartbeat_minutes: number;
11
11
  summary_dry_run: boolean;
12
12
  summary_max_scopes_per_tick: number;
13
+ summary_max_calls_per_tick: number;
13
14
  memory_active_window_days: number;
14
15
  memory_retention_days: number;
15
16
  memory_context_max_chars: number;
@@ -15,6 +15,7 @@ export declare class ProfileManager {
15
15
  private api;
16
16
  private config;
17
17
  constructor(ctx: Context, repo: MemoryRepository, api: APIClient, config: ProfileConfig);
18
+ isEnabled(): boolean;
18
19
  /** 日总结完成后调用;持票用户走自己的 key(与 chat 一致) */
19
20
  summarize(scopeId: string, summary: string, memories: SatMemory[], user?: User | null): Promise<void>;
20
21
  getContext(scopeId: string): Promise<string>;
@@ -12,8 +12,11 @@ export declare class MemoryRepository {
12
12
  getDialoguesAfter(scopeId: string, after: Date, options?: {
13
13
  kinds?: string[];
14
14
  limit?: number;
15
+ before?: Date;
15
16
  }): Promise<SatDialogue[]>;
16
17
  countDialoguesAfter(scopeId: string, after: Date, kinds?: string[]): Promise<number>;
18
+ /** after < createdAt < before 的对话数,用于判断一个已关闭的日块是否归档干净 */
19
+ countDialoguesInRange(scopeId: string, after: Date, before: Date, kinds?: string[]): Promise<number>;
17
20
  countDialogues(scopeId: string): Promise<number>;
18
21
  /** 只清理普通对话,legacy 迁移数据保留(一次性数据,量有界) */
19
22
  pruneDialogues(before: Date): Promise<number>;
@@ -44,7 +47,7 @@ export declare class MemoryRepository {
44
47
  /** 子粒度的最早起点(服务端 ORDER BY periodStart ASC LIMIT 1,等价于 MIN(periodStart)) */
45
48
  getEarliestChildStart(scopeId: string, granularity: Granularity): Promise<Date | null>;
46
49
  /** 区间查询:from < periodEnd <= to,走真正的 SQL 条件而不是 JS 侧过滤 */
47
- getMemoriesInRange(scopeId: string, granularity: Granularity, from: Date | null, to: Date): Promise<SatMemory[]>;
50
+ getMemoriesInRange(scopeId: string, granularity: Granularity, from: Date | null, to: Date, limit?: number): Promise<SatMemory[]>;
48
51
  createMemory(row: Omit<SatMemory, 'id'>): Promise<void>;
49
52
  /** 该 scope 是否已经产过分层记忆(用于判断迁移是否第一次跑) */
50
53
  hasAnyMemory(scopeId: string): Promise<boolean>;
@@ -8,15 +8,31 @@ export declare const TOPIC_MAX_TURNS = 20;
8
8
  export declare const TOPIC_MAX_AGE_HOURS = 24;
9
9
  export declare const MAX_ARCHIVE_DIALOGUES = 200;
10
10
  export declare const MAX_CHILDREN_PER_SUMMARY = 100;
11
+ /** 单个 scope 一次心跳里最多建立多少个层级节点,防止历史积压把 tick 拖死 */
12
+ export declare const MAX_LEVEL_NODES_PER_TICK = 10;
13
+ /** 每次心跳默认的辅助模型调用上限(归档 + 多子节点汇总 + 日画像) */
14
+ export declare const DEFAULT_MAX_CALLS_PER_TICK = 8;
15
+ declare const LEVEL_ORDER: readonly ["day", "week", "month", "season", "year"];
16
+ type Level = typeof LEVEL_ORDER[number];
11
17
  export interface SummarizerConfig {
12
18
  dryRun: boolean;
13
19
  topicSwitchCheck: boolean;
14
20
  timezoneOffset: number;
15
21
  dayBoundaryHour: number;
22
+ /** 每个 scope 每次汇总最多发起多少次辅助模型调用 */
23
+ maxCallsPerTick?: number;
16
24
  /** scopeId -> 用户;用于让持“地灵殿通行证”的用户走自己的 key */
17
25
  resolveUser?: (scopeId: string) => Promise<User | null>;
18
26
  }
19
27
  export declare function formatTime(date: Date, timezoneOffsetHours: number): string;
28
+ /** date 所在自然日的起点:最近一个已过的日界线 */
29
+ export declare function dayStart(date: Date, timezoneOffset: number, boundaryHour: number): Date;
30
+ /** date 所在自然日的终点(下一条日界线) */
31
+ export declare function nextDayStart(date: Date, timezoneOffset: number, boundaryHour: number): Date;
32
+ /** date 所属的 level 周期起点:日 / 周一 / 月 / 季 / 年,均落在日界线上 */
33
+ export declare function periodStart(level: Level, date: Date, timezoneOffset: number, boundaryHour: number): Date;
34
+ /** level 周期的下一个起点(= 当前周期的终点) */
35
+ export declare function periodEnd(level: Level, start: Date, timezoneOffset: number, boundaryHour: number): Date;
20
36
  export declare class Summarizer {
21
37
  private ctx;
22
38
  private repo;
@@ -26,20 +42,39 @@ export declare class Summarizer {
26
42
  /** 同一 scope 同一时刻只跑一个汇总任务,避免心跳与话题切换并发写入 */
27
43
  private busy;
28
44
  constructor(ctx: Context, repo: MemoryRepository, api: APIClient, profile: ProfileManager, config: SummarizerConfig);
45
+ private get tz();
46
+ private get boundaryHour();
29
47
  /**
30
48
  * 每轮对话后调用:必要时归档当前话题。
31
49
  * 注意:这里不阻塞回复,由调用方以 fire-and-forget 方式触发。
32
50
  */
33
51
  maybeSwitchTopic(scopeId: string): Promise<void>;
34
- /** 兜底:话题持续时间过长时强制归档一次,否则金字塔会断在 topic 级 */
35
- private archiveStaleTopic;
36
- /** 把 [topicStart, 末尾] 的对话归档为一条 topic 记忆。调用方需持有 busy 锁。 */
52
+ /**
53
+ * 把游标所在自然日内、尚未归档的对话归档为一条 topic 记忆。
54
+ * 超过 MAX_ARCHIVE_DIALOGUES 时只取前 200 条(同一天拆成多块)。
55
+ * 返回是否真的发起并完成了归档。
56
+ */
37
57
  private archiveTopicLocked;
58
+ /**
59
+ * 按自然日消费积压:每次归档一个日块,直到预算用完。
60
+ * - 全是空白的日期用一次查询跨过去,不逐天循环;
61
+ * - 已关闭的日子必须归档干净(哪怕只剩 1 条),否则日级永远等不到闭合;
62
+ * - 进行中的当天沿用旧语义:话题超过 24 小时才兜底归档。
63
+ */
64
+ private drainTopics;
38
65
  summarizeScope(scopeId: string): Promise<void>;
39
- private summarizeLevel;
66
+ /**
67
+ * 自下而上补齐已闭合的周期,每个周期最多一条节点。
68
+ *
69
+ * 起算点来自该层最新节点的 periodEnd(没有则取最早子节点所在周期),
70
+ * 因此不会重复生成同一周期;空周期没有子节点,会被自然跳过。
71
+ * 周期末尾晚于归档游标时说明周期内的对话还没归档完,立即停止该层。
72
+ */
73
+ private buildLevels;
40
74
  private formatDialogues;
41
75
  private formatChildren;
42
76
  /** 解析失败不应让后台归档整体失败,退回默认辅助模型即可。 */
43
77
  private lookupUser;
44
78
  private runExclusive;
45
79
  }
80
+ export {};
package/lib/types.d.ts CHANGED
@@ -196,6 +196,7 @@ export declare namespace Sat {
196
196
  summary_heartbeat_minutes: number;
197
197
  summary_dry_run: boolean;
198
198
  summary_max_scopes_per_tick: number;
199
+ summary_max_calls_per_tick: number;
199
200
  memory_active_window_days: number;
200
201
  memory_retention_days: number;
201
202
  memory_context_max_chars: number;
package/lib/utils.d.ts CHANGED
@@ -15,9 +15,12 @@ export declare function isErrorWithMessage(error: unknown): error is {
15
15
  };
16
16
  /**
17
17
  * @param prompt 包含待处理标签的原始字符串
18
+ * @param botNames bot 的称呼列表;用于去掉 @bot / 以昵称开场这类「称呼」,
19
+ * 它们只是唤起方式,不应该进入对话记录与总结
20
+ * @param botIds bot 的账号 id;群名片可能改得和正文很像,优先用 id 判断 @ 的是不是 bot
18
21
  * @returns 处理后的字符串,所有匹配标签被替换为对应名称
19
22
  */
20
- export declare function processPrompt(prompt: string): string;
23
+ export declare function processPrompt(prompt: string, botNames?: string[], botIds?: string[]): string;
21
24
  /**
22
25
  * @param prompt 包含待处理标签的原始字符串
23
26
  * @param words 需要过滤的关键词
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-satori-ai",
3
3
  "description": "基于 Satori 适配器的智能对话插件,具备用户画像、长期记忆、常识管理和好感度系统",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "types": "lib/index.d.ts",