luckytri 1.3.0 → 1.3.3
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/docs/en/CHANGELOG.md +16 -0
- package/docs/zh/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/server/core/conversation-grounding.js +216 -1
- package/server/core/conversation-needs.js +10 -3
- package/server/core/dialogue-context.js +171 -26
- package/server/core/dialogue-contract.js +2 -2
- package/server/core/message-scheduler.js +13 -10
- package/server/core/reply-delivery.js +35 -15
- package/server/core/response-validator.js +1 -1
- package/server/core/turn-processor.js +2 -0
- package/server/core/turn.js +20 -1
- package/server/core/understanding.js +60 -4
- package/server/core/vision-manager.js +76 -7
- package/server/i18n/decisions.js +10 -5
- package/server/i18n/errors.js +2 -0
- package/server/mind/guard.js +88 -68
- package/server/mind/relationship-context.js +110 -71
- package/server/mind/time/sharing.js +582 -482
- package/server/mind/time/works.js +51 -48
- package/server/runtime/update-manager.js +34 -2
- package/server/stickers/offer.js +44 -15
package/docs/en/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## 1.3.3 — 2026-10-15
|
|
6
|
+
|
|
7
|
+
- Give each turn a concrete understanding point and open-question cues. Preserve image ownership and unreadable-file state, and catch replies that attach a neighboring topic to the wrong image.
|
|
8
|
+
- Defer work sharing while a group is busy with another topic, require the opening line to frame the user's own new work as a share, and stop injecting unrelated work fragments into ordinary chat.
|
|
9
|
+
- Keep cross-group experiences relevant to the current conversation and classify short greetings, corrections and proposals more accurately.
|
|
10
|
+
|
|
11
|
+
## 1.3.2 — 2026-10-14
|
|
12
|
+
|
|
13
|
+
- Stop filling replies with mechanical fallback phrases after repeated validation failures. Ordinary turns stay quiet; clear crisis turns retain a care response.
|
|
14
|
+
- Avoid treating public identity introductions as private-memory leaks, keep recorded open kinship from being denied by old banter or summaries, and leave life wishes and progress out of affectionate replies.
|
|
15
|
+
- Keep @ mentions attached to their intended message bubble, including when an @-only empty bubble is removed.
|
|
16
|
+
|
|
17
|
+
## 1.3.1 — 2026-10-14
|
|
18
|
+
|
|
19
|
+
- Fix foreground shutdown after updates on Linux and macOS by recognizing unreaped zombie processes as stopped, so the supervisor cannot wait forever. Add a POSIX zombie-process regression test.
|
|
20
|
+
|
|
5
21
|
## 1.3.0 — 2026-10-14
|
|
6
22
|
|
|
7
23
|
- Add optional speech output and speech recognition through Volcengine and OpenAI-compatible services. Voice stays off by default; she chooses line by line whether to speak or sing. Incoming voice can be transcribed, with text fallback when recognition fails.
|
package/docs/zh/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
# 版本记录
|
|
4
4
|
|
|
5
|
+
## 1.3.3 — 2026-10-15
|
|
6
|
+
|
|
7
|
+
- 为每轮回复补上具体理解点和未答问题线索,保留图文发送者、不可读文件状态,拦截把邻近话题误绑到图片上的回复。
|
|
8
|
+
- 群聊正忙于其他话题时延后作品分享;分享开场需先说清是在给对方看自己的新作品,普通聊天也不再误带无关作品片段。
|
|
9
|
+
- 收紧跨群经历的相关性,并修正问候、纠正、提议等短消息的意图分类。
|
|
10
|
+
|
|
11
|
+
## 1.3.2 — 2026-10-14
|
|
12
|
+
|
|
13
|
+
- 回复两次仍未通过校验时,不再用机械短句填空;普通回合保持安静,明确的危机回合仍会给出关心回应。
|
|
14
|
+
- 减少公开身份介绍被误判为泄露私密记忆,也避免旧玩笑或总结否认已经公开的亲属关系;表达想念时不再带入生活愿望与进度。
|
|
15
|
+
- 修正多气泡消息中的 @ 分配,并覆盖清除空 @ 气泡后的提及错位。
|
|
16
|
+
|
|
17
|
+
## 1.3.1 — 2026-10-14
|
|
18
|
+
|
|
19
|
+
- 修正 Linux 与 macOS 的前台实例更新后停止逻辑:将未回收的僵尸进程识别为已退出,避免监管进程永久等待;增加 POSIX 僵尸进程回归测试。
|
|
20
|
+
|
|
5
21
|
## 1.3.0 — 2026-10-14
|
|
6
22
|
|
|
7
23
|
- 新增可选语音输出和语音识别,支持火山语音与 OpenAI 兼容接口。语音默认关闭;她逐句决定是否朗读或唱歌,收到的语音可转成文字,识别失败时继续按文本回退。
|
package/package.json
CHANGED
package/public/guide.en.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
-
<title>LuckyTri v1.3.
|
|
6
|
+
<title>LuckyTri v1.3.3 · Getting Started</title>
|
|
7
7
|
<style>
|
|
8
8
|
* {
|
|
9
9
|
box-sizing: border-box;
|
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
<body>
|
|
270
270
|
<header>
|
|
271
271
|
<div>
|
|
272
|
-
<b>LuckyTri · Handbook</b><small>TIME · MEMORY · CHOICE / v1.3.
|
|
272
|
+
<b>LuckyTri · Handbook</b><small>TIME · MEMORY · CHOICE / v1.3.3</small>
|
|
273
273
|
</div>
|
|
274
274
|
<nav>
|
|
275
275
|
<a href="/#setup">Back to the studio</a
|
|
@@ -1076,7 +1076,7 @@ npm start
|
|
|
1076
1076
|
</div>
|
|
1077
1077
|
<footer>
|
|
1078
1078
|
LuckyTri · so that experience and choice stay coherent. This guide matches
|
|
1079
|
-
the v1.3.
|
|
1079
|
+
the v1.3.3 interface.
|
|
1080
1080
|
</footer>
|
|
1081
1081
|
</body>
|
|
1082
1082
|
</html>
|
package/public/guide.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
-
<title>LuckyTri v1.3.
|
|
6
|
+
<title>LuckyTri v1.3.3 · 使用教程</title>
|
|
7
7
|
<style>
|
|
8
8
|
* {
|
|
9
9
|
box-sizing: border-box;
|
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
<body>
|
|
270
270
|
<header>
|
|
271
271
|
<div>
|
|
272
|
-
<b>LuckyTri · 上手手册</b><small>TIME · MEMORY · CHOICE / v1.3.
|
|
272
|
+
<b>LuckyTri · 上手手册</b><small>TIME · MEMORY · CHOICE / v1.3.3</small>
|
|
273
273
|
</div>
|
|
274
274
|
<nav>
|
|
275
275
|
<a href="/#setup">返回管理台</a
|
|
@@ -883,6 +883,6 @@ npm start
|
|
|
883
883
|
</p>
|
|
884
884
|
</article>
|
|
885
885
|
</div>
|
|
886
|
-
<footer>LuckyTri · 让经历和选择彼此连贯。教程与 v1.3.
|
|
886
|
+
<footer>LuckyTri · 让经历和选择彼此连贯。教程与 v1.3.3 界面同步。</footer>
|
|
887
887
|
</body>
|
|
888
888
|
</html>
|
|
@@ -258,7 +258,7 @@ export function claimChallenged(snapshot) {
|
|
|
258
258
|
);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
export function groundingIssues(bubbles, snapshot) {
|
|
261
|
+
export function groundingIssues(bubbles, snapshot, decision = {}) {
|
|
262
262
|
const recent = recentOwnMessages(snapshot, 6);
|
|
263
263
|
const issues = [];
|
|
264
264
|
const currentActivity = snapshot.inner?.currentLife?.current;
|
|
@@ -522,6 +522,221 @@ export function groundingIssues(bubbles, snapshot) {
|
|
|
522
522
|
);
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
|
+
|
|
526
|
+
issues.push(...mediaAttributionIssues(bubbles, snapshot));
|
|
527
|
+
issues.push(...understandingLedgerIssues(bubbles, snapshot, decision));
|
|
528
|
+
return [...new Set(issues)];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Speak/react must carry a concrete understanding.point. Empty point was the
|
|
532
|
+
// live failure mode (353/353 empty in the 1.3.2 log window).
|
|
533
|
+
export function understandingLedgerIssues(bubbles, snapshot, decision = {}) {
|
|
534
|
+
const issues = [];
|
|
535
|
+
const choice = decision.choice;
|
|
536
|
+
const point = String(decision.understanding?.point || "").trim();
|
|
537
|
+
// Only when the turn explicitly chose to speak; bare groundingIssues(bubbles, snapshot)
|
|
538
|
+
// calls must not all fail for empty point.
|
|
539
|
+
// Only when the turn already produced an understanding object but left point
|
|
540
|
+
// blank. Turns that never set understanding (older tests / silent paths) are
|
|
541
|
+
// gated earlier in takeTurn, not here.
|
|
542
|
+
if (
|
|
543
|
+
["speak", "react"].includes(choice) &&
|
|
544
|
+
!snapshot.initiative &&
|
|
545
|
+
decision.understanding &&
|
|
546
|
+
typeof decision.understanding === "object" &&
|
|
547
|
+
!point
|
|
548
|
+
)
|
|
549
|
+
issues.push(
|
|
550
|
+
"开口前须写清 understanding.point(对方此刻具体意思),不能为空或只复述关键词。",
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const batch = new Set(snapshot.batchIds || []);
|
|
554
|
+
const openQs = (snapshot.messages || []).filter(
|
|
555
|
+
(m) =>
|
|
556
|
+
(batch.has(m.id) || batch.has(Number(m.id))) &&
|
|
557
|
+
m.role === "user" &&
|
|
558
|
+
/[??]|什么|怎么|为什么|哪|有没有|能不能|是什么|啥意思/.test(
|
|
559
|
+
m.text || "",
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
const reply = (bubbles || [])
|
|
563
|
+
.filter((line) => typeof line === "string")
|
|
564
|
+
.join("\n");
|
|
565
|
+
// Question on the table, reply only jokes about the picture / vibes: miss.
|
|
566
|
+
if (openQs.length && reply) {
|
|
567
|
+
const qText = openQs.map((m) => m.text || "").join(" ");
|
|
568
|
+
const qTerms = interestTerms([qText, point]);
|
|
569
|
+
const rTerms = interestTerms([reply]);
|
|
570
|
+
const overlap = [...qTerms].filter(
|
|
571
|
+
(term) => term.length >= 2 && rTerms.has(term),
|
|
572
|
+
);
|
|
573
|
+
const onlyBanter =
|
|
574
|
+
overlap.length === 0 &&
|
|
575
|
+
/(?:挺配|好好笑|哈哈|hh+|表情|玩偶|可爱)/.test(reply) &&
|
|
576
|
+
/是什么|什么意思|啥意思|怎么|为什么/.test(qText);
|
|
577
|
+
if (onlyBanter)
|
|
578
|
+
issues.push(
|
|
579
|
+
"本轮有人在问具体意思;先回应问题本身,不要只接图面或气氛玩笑。",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Unreadable file: do not invent a topic as fact.
|
|
584
|
+
const docs = (snapshot.messages || []).filter(
|
|
585
|
+
(m) =>
|
|
586
|
+
(batch.has(m.id) || batch.has(Number(m.id))) &&
|
|
587
|
+
((m.attachments || []).some((a) =>
|
|
588
|
+
["file", "document", "html", "text"].includes(a?.type),
|
|
589
|
+
) ||
|
|
590
|
+
/\.(?:html?|txt|md)\b/i.test(m.text || "")) &&
|
|
591
|
+
!(m.perceived || []).length,
|
|
592
|
+
);
|
|
593
|
+
if (
|
|
594
|
+
docs.length &&
|
|
595
|
+
/(?:这是|又是|赛博|指南|教程|说明书).{0,12}(?:指南|教程|攻略|说明书|文档)/.test(
|
|
596
|
+
reply,
|
|
597
|
+
) &&
|
|
598
|
+
!/(?:没打开|打不开|还没看|看不清|文件名)/.test(reply)
|
|
599
|
+
)
|
|
600
|
+
issues.push(
|
|
601
|
+
"文件内容没有读到;不要把猜测说成文件主题,按没打开接话或只提文件名。",
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
return issues;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isVisualMessage(message) {
|
|
608
|
+
const text = String(message?.text || "").trim();
|
|
609
|
+
if (/^(?:\[(?:图片|表情|媒体)\]\s*)+$/.test(text)) return true;
|
|
610
|
+
return (message?.attachments || []).some((item) =>
|
|
611
|
+
["image", "mface", "market_face", "sticker"].includes(item?.type),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Catch the live failure mode: a picture from A gets titled with B's earlier
|
|
616
|
+
// topic ("你这实装内容…路线图"), or "你这图" is aimed at someone who did not
|
|
617
|
+
// send the picture in view.
|
|
618
|
+
export function mediaAttributionIssues(bubbles, snapshot) {
|
|
619
|
+
const text = (bubbles || [])
|
|
620
|
+
.filter((line) => typeof line === "string")
|
|
621
|
+
.join("\n");
|
|
622
|
+
if (!text) return [];
|
|
623
|
+
const messages = snapshot.messages || [];
|
|
624
|
+
const batch = new Set(snapshot.batchIds || []);
|
|
625
|
+
const byId = new Map(messages.map((m) => [m.id, m]));
|
|
626
|
+
for (const m of messages) byId.set(String(m.id), m);
|
|
627
|
+
|
|
628
|
+
const media = [];
|
|
629
|
+
const seen = new Set();
|
|
630
|
+
for (const obs of snapshot.vision?.observations || []) {
|
|
631
|
+
const key = String(obs.messageId);
|
|
632
|
+
if (seen.has(key)) continue;
|
|
633
|
+
seen.add(key);
|
|
634
|
+
const message = byId.get(obs.messageId) || byId.get(key);
|
|
635
|
+
media.push({
|
|
636
|
+
messageId: obs.messageId,
|
|
637
|
+
speaker: obs.speaker ?? message?.speaker,
|
|
638
|
+
name: obs.name || message?.name || "",
|
|
639
|
+
description: obs.description || "",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
for (const message of messages) {
|
|
643
|
+
if (!(batch.has(message.id) || batch.has(Number(message.id)))) continue;
|
|
644
|
+
if (!isVisualMessage(message)) continue;
|
|
645
|
+
const key = String(message.id);
|
|
646
|
+
if (seen.has(key)) continue;
|
|
647
|
+
seen.add(key);
|
|
648
|
+
media.push({
|
|
649
|
+
messageId: message.id,
|
|
650
|
+
speaker: message.speaker,
|
|
651
|
+
name: message.name || "",
|
|
652
|
+
description: "",
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
if (!media.length) return [];
|
|
656
|
+
|
|
657
|
+
const issues = [];
|
|
658
|
+
const batchPeople = messages.filter(
|
|
659
|
+
(m) =>
|
|
660
|
+
(batch.has(m.id) || batch.has(Number(m.id))) &&
|
|
661
|
+
m.role === "user" &&
|
|
662
|
+
!isVisualMessage(m),
|
|
663
|
+
);
|
|
664
|
+
const imageOwners = new Set(
|
|
665
|
+
media
|
|
666
|
+
.map((item) => String(item.speaker))
|
|
667
|
+
.filter((id) => id && id !== "undefined"),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
// "你这图" while every text speaker in the batch did not send the picture.
|
|
671
|
+
if (
|
|
672
|
+
/你这(?:张)?图|你发的图|你这表情/.test(text) &&
|
|
673
|
+
batchPeople.length &&
|
|
674
|
+
imageOwners.size &&
|
|
675
|
+
batchPeople.every((person) => !imageOwners.has(String(person.speaker)))
|
|
676
|
+
) {
|
|
677
|
+
const ownerName =
|
|
678
|
+
media.find((item) => item.name)?.name ||
|
|
679
|
+
media.find((item) => item.speaker)?.speaker ||
|
|
680
|
+
"别人";
|
|
681
|
+
issues.push(
|
|
682
|
+
`在场的图是${ownerName}发的,不是本轮说话的人发的;不要把别人的图说成对方的态度或内容。`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
for (const item of media) {
|
|
687
|
+
if (item.speaker == null) continue;
|
|
688
|
+
const descTerms = interestTerms([item.description || ""]);
|
|
689
|
+
const talksAboutImage =
|
|
690
|
+
[...descTerms].some((term) => term.length >= 2 && text.includes(term)) ||
|
|
691
|
+
(/这(?:张)?图|图里|截图|画面|路线图/.test(text) &&
|
|
692
|
+
(batch.has(item.messageId) || batch.has(Number(item.messageId))));
|
|
693
|
+
if (!talksAboutImage) continue;
|
|
694
|
+
|
|
695
|
+
const ownerId = String(item.speaker);
|
|
696
|
+
const priorOthers = messages
|
|
697
|
+
.filter(
|
|
698
|
+
(m) =>
|
|
699
|
+
m.role === "user" &&
|
|
700
|
+
String(m.speaker) !== ownerId &&
|
|
701
|
+
!batch.has(m.id) &&
|
|
702
|
+
!batch.has(Number(m.id)) &&
|
|
703
|
+
(m.text || "").length > 2 &&
|
|
704
|
+
!isVisualMessage(m),
|
|
705
|
+
)
|
|
706
|
+
.slice(-24);
|
|
707
|
+
const ownerLines = messages
|
|
708
|
+
.filter(
|
|
709
|
+
(m) =>
|
|
710
|
+
String(m.speaker) === ownerId &&
|
|
711
|
+
(m.text || "").length > 2 &&
|
|
712
|
+
!isVisualMessage(m),
|
|
713
|
+
)
|
|
714
|
+
.slice(-24);
|
|
715
|
+
const otherTerms = interestTerms(priorOthers.map((m) => m.text));
|
|
716
|
+
const ownerTerms = interestTerms([
|
|
717
|
+
...ownerLines.map((m) => m.text),
|
|
718
|
+
item.description || "",
|
|
719
|
+
]);
|
|
720
|
+
// Prefer longer foreign phrases so common bigrams do not dominate.
|
|
721
|
+
const foreign = [...otherTerms].filter(
|
|
722
|
+
(term) =>
|
|
723
|
+
term.length >= 2 &&
|
|
724
|
+
text.includes(term) &&
|
|
725
|
+
!ownerTerms.has(term) &&
|
|
726
|
+
!descTerms.has(term),
|
|
727
|
+
);
|
|
728
|
+
const notable = foreign.filter((term) => term.length >= 2);
|
|
729
|
+
const glues =
|
|
730
|
+
/你这.{0,16}(?:内容|材料|东西|附件)|(?:版本更新|实装).{0,16}(?:附赠|内容|材料)|附赠.{0,12}(?:材料|内容|阅读)|归到.{0,12}头上|当成.{0,12}(?:实装|更新|内容)|你这实装/.test(
|
|
731
|
+
text,
|
|
732
|
+
) ||
|
|
733
|
+
(notable.length > 0 &&
|
|
734
|
+
/你这|你发的|你给我塞|跟我有啥关系|版本更新附赠/.test(text));
|
|
735
|
+
if (glues && notable.length)
|
|
736
|
+
issues.push(
|
|
737
|
+
`图是${item.name || "发图的人"}发的;不要把别人先前的话题(如「${notable.slice(0, 3).join("、")}」)说成这张图的内容或归属。`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
525
740
|
return [...new Set(issues)];
|
|
526
741
|
}
|
|
527
742
|
|
|
@@ -23,10 +23,17 @@ export function conversationNeeds(snapshot) {
|
|
|
23
23
|
const recent = (snapshot.messages || [])
|
|
24
24
|
.filter((m) => !m.referenceOnly)
|
|
25
25
|
.slice(-12);
|
|
26
|
+
const works = snapshot.inner?.currentLife?.works || [];
|
|
27
|
+
// Works in view are not enough: only open creative guidance when the talk
|
|
28
|
+
// itself is about writing, or someone named a specific work on the table.
|
|
29
|
+
const namedWork = works.some((w) => {
|
|
30
|
+
const title = String(w?.title || "");
|
|
31
|
+
return (
|
|
32
|
+
title.length >= 2 && recent.some((m) => (m.text || "").includes(title))
|
|
33
|
+
);
|
|
34
|
+
});
|
|
26
35
|
return {
|
|
27
|
-
creative:
|
|
28
|
-
recent.some((m) => WRITING.test(m.text || "")) ||
|
|
29
|
-
(snapshot.inner?.currentLife?.works || []).length > 0,
|
|
36
|
+
creative: recent.some((m) => WRITING.test(m.text || "")) || namedWork,
|
|
30
37
|
bots: recent.some((m) => m.role === "user" && bots.has(String(m.speaker))),
|
|
31
38
|
};
|
|
32
39
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { announcesPrivacy } from "../mind/discretion.js";
|
|
2
|
+
import { softenRelationDenialSummaries } from "../mind/relationship-context.js";
|
|
2
3
|
import { isPrivateSession } from "../mind/memory.js";
|
|
3
4
|
import {
|
|
4
5
|
conversationGrounding,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
} from "./conversation-grounding.js";
|
|
9
10
|
import { relevantContext } from "../mind/context-selection.js";
|
|
10
11
|
import { interestTerms } from "../mind/attention.js";
|
|
12
|
+
import { intentHint, openQuestions } from "./understanding.js";
|
|
11
13
|
|
|
12
14
|
// A small, factual tail after the larger memory context. It preserves message
|
|
13
15
|
// ownership and quote chains; it does not guess intent from isolated keywords.
|
|
@@ -27,10 +29,45 @@ export function currentExchange(snapshot) {
|
|
|
27
29
|
id = source.replyTo?.seq ?? source.replyTo;
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
const media = mediaInView(snapshot);
|
|
30
33
|
return {
|
|
31
34
|
current,
|
|
32
35
|
quoted: [...quoted.values()],
|
|
33
36
|
recentOwnWords: recentOwnMessages(snapshot, 3),
|
|
37
|
+
// Who owns each fresh line (image vs short reply vs text). Keeps a picture
|
|
38
|
+
// from being silently titled with a neighbour's topic.
|
|
39
|
+
ownership: current.map((m) => {
|
|
40
|
+
const kind = messageKind(m);
|
|
41
|
+
const parentId = m.replyTo?.seq ?? m.replyTo ?? m.replyChain?.[0];
|
|
42
|
+
const parent = parentId != null ? byId.get(parentId) : null;
|
|
43
|
+
const hint = m.role === "user" ? intentHint(m.text) : null;
|
|
44
|
+
return {
|
|
45
|
+
id: m.id,
|
|
46
|
+
speaker: m.speaker,
|
|
47
|
+
name: m.name,
|
|
48
|
+
kind,
|
|
49
|
+
...(hint ? { intentHint: hint } : {}),
|
|
50
|
+
...(parent
|
|
51
|
+
? {
|
|
52
|
+
repliesTo: {
|
|
53
|
+
id: parent.id,
|
|
54
|
+
speaker: parent.speaker,
|
|
55
|
+
name: parent.name,
|
|
56
|
+
text: parent.text,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
...(media.length ? { mediaInView: media } : {}),
|
|
63
|
+
...(() => {
|
|
64
|
+
const docs = documentsInView(snapshot);
|
|
65
|
+
return docs.length ? { documentsInView: docs } : {};
|
|
66
|
+
})(),
|
|
67
|
+
...(() => {
|
|
68
|
+
const qs = openQuestions(messages, snapshot.batchIds || []);
|
|
69
|
+
return qs.length ? { openQuestions: qs } : {};
|
|
70
|
+
})(),
|
|
34
71
|
// humanMessages used to mirror current's human rows and doubled the turn
|
|
35
72
|
// payload (~0.7–1.5k chars) without being referenced in any prompt.
|
|
36
73
|
thirdPartyClaims: current
|
|
@@ -48,8 +85,83 @@ export function currentExchange(snapshot) {
|
|
|
48
85
|
"这是当前说话人对别人的解释;须核对那个人自己的原话,不能视作本人确认",
|
|
49
86
|
})),
|
|
50
87
|
instruction:
|
|
51
|
-
"
|
|
88
|
+
"先读本轮原话、引用和补充,确定谁在对谁说、具体问什么或表达什么;understanding.point 必须写清对方此刻意思并对齐 openQuestions / intentHint。同批有人认真提问或想加入时,别因后面有人刷hh或另一机器人自说自话而漏掉。人和机器人都按实际内容理解,后者的纠正没有裁定别人意思的权力。历史只用于解释这轮,不替换眼前的问题。缩写和省略从同一话题找;多个合理对象就轻问,不把自己的猜测限定成唯二选项。自己的旧话是待核对的记录,不是正确答案或说话模板。图只属于 ownership / mediaInView 标明的发送者;邻句文字话题不能自动成为别人发的图的标题。短回复优先看 repliesTo 原话,不按时间邻近另猜对象。documentsInView 标 unreadable 的文件没有打开,不要把猜测当文件内容。",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const IMAGE_ONLY = /^(?:\[(?:图片|表情|媒体)\]\s*)+$/;
|
|
93
|
+
|
|
94
|
+
function messageKind(message) {
|
|
95
|
+
const text = String(message?.text || "").trim();
|
|
96
|
+
const visual = (message?.attachments || []).some((item) =>
|
|
97
|
+
["image", "mface", "market_face", "sticker"].includes(item?.type),
|
|
98
|
+
);
|
|
99
|
+
if (visual || IMAGE_ONLY.test(text)) return "image";
|
|
100
|
+
const bare = text.replace(/\[提及成员\]|@\S+/g, "").trim();
|
|
101
|
+
if (bare && bare.length <= 8) return "short";
|
|
102
|
+
return "text";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pictures she can see this turn, each still attached to who sent them.
|
|
106
|
+
export function mediaInView(snapshot) {
|
|
107
|
+
const byId = new Map((snapshot.messages || []).map((m) => [m.id, m]));
|
|
108
|
+
for (const m of snapshot.messages || []) byId.set(String(m.id), m);
|
|
109
|
+
const batch = new Set(snapshot.batchIds || []);
|
|
110
|
+
const rows = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const push = (messageId, description, speaker, name) => {
|
|
113
|
+
const key = String(messageId);
|
|
114
|
+
if (seen.has(key)) return;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
const message = byId.get(messageId) || byId.get(key);
|
|
117
|
+
rows.push({
|
|
118
|
+
messageId,
|
|
119
|
+
speaker: speaker ?? message?.speaker,
|
|
120
|
+
name: name || message?.name || "",
|
|
121
|
+
description: description || "",
|
|
122
|
+
inBatch: batch.has(messageId) || batch.has(Number(messageId)),
|
|
123
|
+
});
|
|
52
124
|
};
|
|
125
|
+
for (const obs of snapshot.vision?.observations || [])
|
|
126
|
+
push(obs.messageId, obs.description, obs.speaker, obs.name);
|
|
127
|
+
for (const message of snapshot.messages || []) {
|
|
128
|
+
if (!(batch.has(message.id) || batch.has(Number(message.id)))) continue;
|
|
129
|
+
if (messageKind(message) !== "image") continue;
|
|
130
|
+
push(message.id, "", message.speaker, message.name);
|
|
131
|
+
}
|
|
132
|
+
return rows.filter((row) => row.speaker != null || row.description);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// File / html attachments she can see this turn. Readable ones carry perceived
|
|
136
|
+
// text; others are marked unreadable so she does not invent their contents.
|
|
137
|
+
export function documentsInView(snapshot) {
|
|
138
|
+
const batch = new Set(snapshot.batchIds || []);
|
|
139
|
+
const rows = [];
|
|
140
|
+
for (const message of snapshot.messages || []) {
|
|
141
|
+
if (!(batch.has(message.id) || batch.has(Number(message.id)))) continue;
|
|
142
|
+
const files = (message.attachments || []).filter((item) =>
|
|
143
|
+
["file", "document", "html", "text"].includes(item?.type),
|
|
144
|
+
);
|
|
145
|
+
if (
|
|
146
|
+
!files.length &&
|
|
147
|
+
!/\.(?:html?|txt|md)(?:\s|$)/i.test(message.text || "")
|
|
148
|
+
)
|
|
149
|
+
continue;
|
|
150
|
+
const perceived = (message.perceived || []).filter(Boolean);
|
|
151
|
+
const name =
|
|
152
|
+
files.map((f) => f.name || f.file || "").filter(Boolean)[0] ||
|
|
153
|
+
String(message.text || "").slice(0, 80);
|
|
154
|
+
rows.push({
|
|
155
|
+
messageId: message.id,
|
|
156
|
+
speaker: message.speaker,
|
|
157
|
+
name: message.name || "",
|
|
158
|
+
fileName: name,
|
|
159
|
+
...(perceived.length
|
|
160
|
+
? { excerpt: perceived.join(" ").slice(0, 240), readable: true }
|
|
161
|
+
: { readable: false, unreadable: true }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return rows;
|
|
53
165
|
}
|
|
54
166
|
|
|
55
167
|
// Direct "想你了" / affection: she should answer from the relationship, not
|
|
@@ -238,6 +350,20 @@ export function dialogueContext(snapshot) {
|
|
|
238
350
|
delete inner.stood;
|
|
239
351
|
delete inner.will;
|
|
240
352
|
livedView(inner, self);
|
|
353
|
+
// Ordinary turns: only keep justLived lines that overlap the current cue;
|
|
354
|
+
// otherwise drop them so other-room life does not steal the reply.
|
|
355
|
+
if (inner.justLived?.lines) {
|
|
356
|
+
const cueTerms = interestTerms([currentWords]);
|
|
357
|
+
inner.justLived = {
|
|
358
|
+
note: "你刚在别的群聊里经历的事。只有和眼前话题有关时才顺口提一句背景。",
|
|
359
|
+
lines: inner.justLived.lines.filter((line) =>
|
|
360
|
+
[...interestTerms([line.text || ""])].some(
|
|
361
|
+
(term) => term.length >= 2 && cueTerms.has(term),
|
|
362
|
+
),
|
|
363
|
+
),
|
|
364
|
+
};
|
|
365
|
+
if (!inner.justLived.lines.length) delete inner.justLived;
|
|
366
|
+
}
|
|
241
367
|
const worn = wornPhrases(snapshot.messages || []);
|
|
242
368
|
if (worn.length)
|
|
243
369
|
context.working = {
|
|
@@ -247,38 +373,57 @@ export function dialogueContext(snapshot) {
|
|
|
247
373
|
note: "这些是你最近说得太多的词,这次换成你自己平常的说法",
|
|
248
374
|
},
|
|
249
375
|
};
|
|
250
|
-
if (
|
|
251
|
-
// Affection first: keep only that she is still herself
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const life = inner.currentLife;
|
|
261
|
-
const compact = (row) => ({
|
|
262
|
-
id: row.id,
|
|
263
|
-
title: row.title,
|
|
264
|
-
state: row.state,
|
|
265
|
-
...(row.wait ? { wait: row.wait } : {}),
|
|
266
|
-
...(row.plannedFor ? { plannedFor: row.plannedFor } : {}),
|
|
267
|
-
});
|
|
268
|
-
inner.currentLife = {
|
|
269
|
-
...life,
|
|
270
|
-
pending: (life.pending || []).map(compact),
|
|
271
|
-
intentions: (life.intentions || []).map(compact),
|
|
272
|
-
worksCoverage:
|
|
273
|
-
"以下是按本轮话题选出的作品片段,不是全部历史;缺少一篇不能证明从未写过或读过。",
|
|
274
|
-
};
|
|
376
|
+
if (affectionBatch(snapshot)) {
|
|
377
|
+
// Affection first: keep only that she is still herself. Drop progress,
|
|
378
|
+
// justLived's "顺口给句背景", and livingFor wishes so a "想你了" is not
|
|
379
|
+
// answered with a reading report or a work-volume question.
|
|
380
|
+
delete inner.currentLife;
|
|
381
|
+
delete inner.justLived;
|
|
382
|
+
if (self) delete self.livingFor;
|
|
383
|
+
if (inner.selfKnown && typeof inner.selfKnown === "object") {
|
|
384
|
+
const { livingFor: _wish, ...rest } = inner.selfKnown;
|
|
385
|
+
inner.selfKnown = rest;
|
|
275
386
|
}
|
|
387
|
+
} else if (inner.currentLife) {
|
|
388
|
+
const life = inner.currentLife;
|
|
389
|
+
const compact = (row) => ({
|
|
390
|
+
id: row.id,
|
|
391
|
+
title: row.title,
|
|
392
|
+
state: row.state,
|
|
393
|
+
...(row.wait ? { wait: row.wait } : {}),
|
|
394
|
+
...(row.plannedFor ? { plannedFor: row.plannedFor } : {}),
|
|
395
|
+
});
|
|
396
|
+
inner.currentLife = {
|
|
397
|
+
...life,
|
|
398
|
+
pending: (life.pending || []).map(compact),
|
|
399
|
+
intentions: (life.intentions || []).map(compact),
|
|
400
|
+
worksCoverage:
|
|
401
|
+
"以下是按本轮话题选出的作品片段,不是全部历史;缺少一篇不能证明从未写过或读过。",
|
|
402
|
+
};
|
|
276
403
|
}
|
|
404
|
+
if (Array.isArray(context.summaries))
|
|
405
|
+
context.summaries = softenRelationDenialSummaries(
|
|
406
|
+
context.summaries,
|
|
407
|
+
inner.relationships,
|
|
408
|
+
);
|
|
409
|
+
const media = mediaInView(snapshot);
|
|
277
410
|
return {
|
|
278
411
|
...context,
|
|
279
412
|
self,
|
|
280
413
|
inner,
|
|
281
414
|
grounding: conversationGrounding(snapshot),
|
|
415
|
+
...(media.length
|
|
416
|
+
? {
|
|
417
|
+
vision: {
|
|
418
|
+
observations: media.map((row) => ({
|
|
419
|
+
messageId: row.messageId,
|
|
420
|
+
speaker: row.speaker,
|
|
421
|
+
name: row.name,
|
|
422
|
+
description: row.description,
|
|
423
|
+
})),
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
: {}),
|
|
282
427
|
};
|
|
283
428
|
}
|
|
284
429
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the core of them, said once.
|
|
5
5
|
const FACT_CORE =
|
|
6
6
|
"【事实】先弄清眼前谁在说、说的是哪件事。原话和引用比概括和旧印象可靠。描述别人的项目、计划和经历时紧贴原话;不知道别人项目、软件、作品的细节就别把猜测说成事实;不给别人编出处、编背景、编动机;不知道就说不知道,不假装知道内情。";
|
|
7
|
-
export const DIALOGUE_TASK =
|
|
7
|
+
export const DIALOGUE_TASK = `这是你自己的聊天。开口前先写清 understanding.point:对方此刻具体想表达、想知道或邀请的事(要具体,不能空,也不能只复述关键词);messageIds 只填你真正在回应的那些。再像个人一样被触动,然后开口。
|
|
8
8
|
felt:用第一人称写一句此刻真实的反应——好笑、心疼、不服、好奇、无语、开心、懒得理都行,这是你的情绪,不是对消息的分析。
|
|
9
9
|
want:你此刻想干嘛——接梗、顶回去、站队、安慰、吐槽、说自己的看法、问一句真好奇的事、不理。由你的心情和你跟这些人的关系决定。
|
|
10
10
|
然后 say 你会在 QQ 里随手打的话:
|
|
@@ -23,7 +23,7 @@ want:你此刻想干嘛——接梗、顶回去、站队、安慰、吐槽、
|
|
|
23
23
|
- 连续追问通常是在补问不同细节:只回答新问的部分,问来源就说来源,问感受就说感受,不重讲说过的限制。
|
|
24
24
|
- 已经聊完、没有想说的,就 silent。
|
|
25
25
|
事实:只说聊天里、记忆里真有的事;不编自己做过的事、不编别人的话和背景;记不清就说记不清。
|
|
26
|
-
输出 JSON:{"felt":"一句第一人称反应","want":"想做的事","choice":"speak|react|decline|silent","targetMessageIds":[最想接的1到2条batchIds序号],"topic":"当前话题","say":"实际说的话,换行就是按了发送、另起一条","at":["可选,只从 expression.canAt 选 userId"],"sticker":"可选,expression.stickers 里的 ref","actions":"可选,只从 expression.actions 选","understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[],"tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"一句话:你说的意思"},"appraisal":"这件事对自己的实际影响,没有则空","reason":"一句话为何这样选,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。understanding
|
|
26
|
+
输出 JSON:{"felt":"一句第一人称反应","want":"想做的事","choice":"speak|react|decline|silent","targetMessageIds":[最想接的1到2条batchIds序号],"topic":"当前话题","say":"实际说的话,换行就是按了发送、另起一条","at":["可选,只从 expression.canAt 选 userId"],"sticker":"可选,expression.stickers 里的 ref","actions":"可选,只从 expression.actions 选","understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[],"point":"对方此刻的具体意思,speak/react 时必填","tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"一句话:你说的意思"},"appraisal":"这件事对自己的实际影响,没有则空","reason":"一句话为何这样选,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。understanding.point 在 speak/react 时必填;tone/stance 拿不准可不写。silent 时 say 为空。亲近和信任只在对方实际行为改变了相处感觉时才写 bonds。真实自伤危机认真回应眼前安全,crisis.clear=true。`;
|
|
27
27
|
|
|
28
28
|
export const DIALOGUE_REWRITE = `重新核对 exchange.current 和引用,修改 draft 中被指出的具体问题。decision.understanding 可能也有误,以原话为准。保留成立的内容和你的态度、语气,只改被指出的地方,不改成稳妥、全面或认错的说法;不要把修改写成新的认错套话或内部状态报告。self.livingFor 只作背景,不必提起。输出 {"bubbles":["实际内容"],"reason":"为何这样说"}。`;
|
|
29
29
|
|