luckytri 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +14 -12
- package/README.md +4 -2
- package/docs/en/CHANGELOG.md +24 -0
- package/docs/en/architecture.md +12 -10
- package/docs/en/chat-style.md +13 -13
- package/docs/en/cognition.md +12 -4
- package/docs/en/evolution.md +35 -0
- package/docs/en/her-life.md +5 -2
- package/docs/en/vision.md +50 -0
- package/docs/zh/CHANGELOG.md +24 -0
- package/docs/zh/architecture.md +12 -10
- package/docs/zh/chat-style.md +17 -17
- package/docs/zh/cognition.md +12 -4
- package/docs/zh/evolution.md +35 -0
- package/docs/zh/her-life.md +5 -2
- package/docs/zh/vision.md +50 -0
- package/package.json +4 -1
- package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
- package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
- package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
- package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
- package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
- package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
- package/public/app/assets/NaturePage-DniImo9U.js +3 -0
- package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
- package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
- package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
- package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
- package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
- package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
- package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
- package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
- package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
- package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
- package/public/app/index.html +3 -3
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/scripts/migrate-instance.js +27 -0
- package/scripts/update-instance.js +21 -2
- package/server/channels/contract.js +5 -1
- package/server/channels/hub.js +2 -2
- package/server/channels/onebot/adapter.js +291 -288
- package/server/channels/onebot/index.js +2 -2
- package/server/channels/qqbot/api.js +194 -199
- package/server/core/context-builder.js +20 -3
- package/server/core/conversation-cues.js +5 -4
- package/server/core/conversation-grounding.js +20 -17
- package/server/core/conversation-needs.js +32 -0
- package/server/core/dialogue-context.js +2 -5
- package/server/core/dialogue-contract.js +23 -16
- package/server/core/message-scheduler.js +85 -78
- package/server/core/model-manager.js +113 -16
- package/server/core/model-presets.js +1 -1
- package/server/core/orchestrator.js +18 -2
- package/server/core/persona-manager.js +14 -5
- package/server/core/reader-check.js +47 -42
- package/server/core/reply-delivery.js +125 -34
- package/server/core/response-validator.js +28 -1
- package/server/core/retired-prompts.js +5 -0
- package/server/core/speech-length.js +43 -0
- package/server/core/turn-processor.js +38 -5
- package/server/core/turn.js +70 -16
- package/server/i18n/errors.js +30 -0
- package/server/i18n/labels.js +5 -0
- package/server/index.js +3 -0
- package/server/knowledge/embedding-profile.js +126 -29
- package/server/knowledge/manager.js +103 -34
- package/server/knowledge/schema.js +197 -158
- package/server/mind/affect.js +267 -265
- package/server/mind/aftermath.js +169 -0
- package/server/mind/anticipations.js +2 -0
- package/server/mind/clock.js +91 -83
- package/server/mind/discretion.js +20 -0
- package/server/mind/episodes.js +2 -1
- package/server/mind/index.js +17 -2
- package/server/mind/initiative.js +29 -14
- package/server/mind/memory.js +47 -5
- package/server/mind/recall/cue.js +67 -0
- package/server/mind/recall/index.js +183 -29
- package/server/mind/recall/roots.js +231 -0
- package/server/mind/recall/schema.js +2 -0
- package/server/mind/recall/store.js +113 -30
- package/server/mind/salience.js +22 -1
- package/server/mind/schema.js +5 -0
- package/server/mind/thoughts.js +276 -274
- package/server/mind/view.js +100 -60
- package/server/plugins/install.js +1 -6
- package/server/plugins/zip.js +18 -4
- package/server/runtime/data-migration.js +193 -0
- package/server/runtime/update-execution.js +43 -4
- package/server/runtime/update-manager.js +4 -1
- package/server/storage/archive.js +1 -1
- package/server/storage/backup-scheduler.js +15 -3
- package/public/app/assets/NaturePage-C7i607ul.js +0 -3
- package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// What happened after she chose: whether anyone answered her, how they
|
|
2
|
+
// sounded, whether she may have been corrected, whether someone called her
|
|
3
|
+
// again after she kept quiet, and how the owner tagged the reply. Read only
|
|
4
|
+
// from the record (events, message cues, reply feedback), never from a
|
|
5
|
+
// model, so it can be cleared and computed again. It is an experience to
|
|
6
|
+
// look back on, not a score: nothing here is summed into a reward.
|
|
7
|
+
|
|
8
|
+
const HOUR = 3600000;
|
|
9
|
+
export const WINDOW_MS = 2 * HOUR;
|
|
10
|
+
export const WINDOW_MESSAGES = 20;
|
|
11
|
+
// Said to her, these usually mean she got something wrong. Only a hint:
|
|
12
|
+
// "可能" is all it ever claims, with the message beside it.
|
|
13
|
+
const CORRECTION =
|
|
14
|
+
/(?:^|[,,。!!??\s])(?:你|您)?(?:不是(?:的|啦|吧)?|不对|没有啦|才不是|搞错|记错|说错|弄错|错了|并没有|不是这样)/;
|
|
15
|
+
|
|
16
|
+
const parse = (value) => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(value);
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function migrateAftermath(addColumn) {
|
|
25
|
+
// What happened next, as JSON; '' until the window has closed.
|
|
26
|
+
addColumn("mind_choices", "after", "TEXT NOT NULL DEFAULT ''");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The window closes at her next choice in the room, two hours after this
|
|
30
|
+
// one, or twenty messages later, whichever comes first. The message that
|
|
31
|
+
// moved her to choose again still belongs to this window.
|
|
32
|
+
function windowEnd(db, choice) {
|
|
33
|
+
const next = db
|
|
34
|
+
.prepare(
|
|
35
|
+
"SELECT MIN(created) at FROM mind_choices WHERE session_id=? AND created>? ",
|
|
36
|
+
)
|
|
37
|
+
.get(choice.session_id, choice.created)?.at;
|
|
38
|
+
return Math.min(next ?? Infinity, choice.created + WINDOW_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Pure read: what came after one choice, or null while the window is open.
|
|
42
|
+
export function aftermathOf(db, choice, now) {
|
|
43
|
+
const end = windowEnd(db, choice);
|
|
44
|
+
if (end === choice.created + WINDOW_MS && now < end) return null;
|
|
45
|
+
const spoke = choice.choice !== "silent";
|
|
46
|
+
const from = Number(choice.watermark) || 0;
|
|
47
|
+
const rows = db
|
|
48
|
+
.prepare(
|
|
49
|
+
"SELECT seq,role,time,payload FROM core_events WHERE session_id=? AND seq>? AND time>=? AND time<=? ORDER BY seq LIMIT ?",
|
|
50
|
+
)
|
|
51
|
+
.all(choice.session_id, from, choice.created, end, WINDOW_MESSAGES + 10);
|
|
52
|
+
// When she spoke, what others said before her words landed is not an
|
|
53
|
+
// answer to them.
|
|
54
|
+
const own = rows.findIndex((row) => row.role === "assistant");
|
|
55
|
+
const start = spoke && own >= 0 ? own + 1 : 0;
|
|
56
|
+
const after = rows
|
|
57
|
+
.slice(start)
|
|
58
|
+
.filter((row) => row.role !== "assistant")
|
|
59
|
+
.slice(0, WINDOW_MESSAGES)
|
|
60
|
+
.map((row) => ({ ...row, m: parse(row.payload) }));
|
|
61
|
+
const toHer = after.filter(
|
|
62
|
+
(row) => row.m.replyToBot === true || row.m.mentioned === true,
|
|
63
|
+
);
|
|
64
|
+
const cue = db.prepare("SELECT tone FROM core_message_cues WHERE seq=?");
|
|
65
|
+
const tone = [
|
|
66
|
+
...new Set(
|
|
67
|
+
toHer
|
|
68
|
+
.map((row) => {
|
|
69
|
+
try {
|
|
70
|
+
return cue.get(row.seq)?.tone;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.filter(Boolean),
|
|
76
|
+
),
|
|
77
|
+
];
|
|
78
|
+
const corrected = spoke
|
|
79
|
+
? toHer.filter((row) => CORRECTION.test(String(row.m.text || "")))
|
|
80
|
+
: [];
|
|
81
|
+
// The owner's tag sits on the logged reply, whose text is what she sent.
|
|
82
|
+
let feedback = [];
|
|
83
|
+
if (spoke && own >= 0) {
|
|
84
|
+
const sent = [];
|
|
85
|
+
for (let i = own; i < rows.length && rows[i].role === "assistant"; i++)
|
|
86
|
+
sent.push(String(parse(rows[i].payload).text || ""));
|
|
87
|
+
try {
|
|
88
|
+
feedback = db
|
|
89
|
+
.prepare(
|
|
90
|
+
"SELECT f.tag FROM decisions d JOIN reply_feedback f ON f.decision_id=d.id WHERE d.session_id=? AND d.reply=? AND COALESCE(d.is_demo,0)=0 ORDER BY d.id DESC LIMIT 1",
|
|
91
|
+
)
|
|
92
|
+
.all(choice.session_id, sent.join("\n"))
|
|
93
|
+
.map((row) => row.tag);
|
|
94
|
+
} catch {
|
|
95
|
+
feedback = [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const first = toHer[0];
|
|
99
|
+
return {
|
|
100
|
+
v: 1,
|
|
101
|
+
spoke,
|
|
102
|
+
answered: spoke ? toHer.length > 0 : undefined,
|
|
103
|
+
calledAgain: spoke ? undefined : toHer.length > 0,
|
|
104
|
+
toHer: toHer.length,
|
|
105
|
+
others: after.length,
|
|
106
|
+
firstMin: first
|
|
107
|
+
? Math.max(0, Math.round((first.time - choice.created) / 60000))
|
|
108
|
+
: null,
|
|
109
|
+
...(tone.length ? { tone } : {}),
|
|
110
|
+
...(corrected.length ? { corrected: "maybe" } : {}),
|
|
111
|
+
...(feedback.length ? { feedback: [...new Set(feedback)] } : {}),
|
|
112
|
+
...(spoke && own >= 0 ? { own: `m:${rows[own].seq}` } : {}),
|
|
113
|
+
refs: [...corrected, ...toHer.filter((row) => !corrected.includes(row))]
|
|
114
|
+
.slice(0, 4)
|
|
115
|
+
.map((row) => `m:${row.seq}`),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Writes what came after for choices whose window has closed, oldest first,
|
|
120
|
+
// a page at a time. Returns how many were written.
|
|
121
|
+
export function settleAftermath(db, { now, limit = 50 } = {}) {
|
|
122
|
+
const rows = db
|
|
123
|
+
.prepare(
|
|
124
|
+
`SELECT c.id,c.created,c.session_id,c.choice,c.watermark FROM mind_choices c
|
|
125
|
+
WHERE c.after='' AND (c.created<=? OR EXISTS (SELECT 1 FROM mind_choices n WHERE n.session_id=c.session_id AND n.created>c.created))
|
|
126
|
+
ORDER BY c.created LIMIT ?`,
|
|
127
|
+
)
|
|
128
|
+
.all(now - WINDOW_MS, limit);
|
|
129
|
+
if (!rows.length) return 0;
|
|
130
|
+
const write = db.prepare(
|
|
131
|
+
"UPDATE mind_choices SET after=? WHERE id=? AND after=''",
|
|
132
|
+
);
|
|
133
|
+
let written = 0;
|
|
134
|
+
db.exec("SAVEPOINT aftermath");
|
|
135
|
+
try {
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const after = aftermathOf(db, row, now);
|
|
138
|
+
if (after) written += write.run(JSON.stringify(after), row.id).changes;
|
|
139
|
+
}
|
|
140
|
+
db.exec("RELEASE aftermath");
|
|
141
|
+
} catch (error) {
|
|
142
|
+
db.exec("ROLLBACK TO aftermath");
|
|
143
|
+
db.exec("RELEASE aftermath");
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
return written;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// How the record reads back in a line, for her and for the audit.
|
|
150
|
+
export function describeAftermath(after) {
|
|
151
|
+
if (!after || typeof after !== "object") return "";
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (after.spoke) {
|
|
154
|
+
parts.push(
|
|
155
|
+
after.answered
|
|
156
|
+
? `有人接了我的话${after.firstMin != null ? `(${after.firstMin} 分钟后)` : ""}`
|
|
157
|
+
: after.others
|
|
158
|
+
? "没人接我的话,大家接着聊别的"
|
|
159
|
+
: "说完以后没人再说话",
|
|
160
|
+
);
|
|
161
|
+
if (after.corrected) parts.push("可能说错了,被纠正");
|
|
162
|
+
} else {
|
|
163
|
+
parts.push(after.calledAgain ? "我没出声,之后又有人叫我" : "我没出声");
|
|
164
|
+
}
|
|
165
|
+
if (after.tone?.length) parts.push(`对方的语气:${after.tone.join("、")}`);
|
|
166
|
+
if (after.feedback?.length)
|
|
167
|
+
parts.push(`主人的反馈:${after.feedback.join("、")}`);
|
|
168
|
+
return parts.join(";");
|
|
169
|
+
}
|
|
@@ -268,6 +268,8 @@ export class Anticipations {
|
|
|
268
268
|
origin,
|
|
269
269
|
kind === "plan" && isExecutable(activity) ? activity : "",
|
|
270
270
|
);
|
|
271
|
+
// Searchable, and dated in the audit, from the moment it is made.
|
|
272
|
+
this.mind.recall?.sync("anticipation", id);
|
|
271
273
|
return { id };
|
|
272
274
|
}
|
|
273
275
|
get(id) {
|
package/server/mind/clock.js
CHANGED
|
@@ -1,83 +1,91 @@
|
|
|
1
|
-
import { localClock } from "../core/conversation-cues.js";
|
|
2
|
-
|
|
3
|
-
export const JUST_NOW = 5 * 60000;
|
|
4
|
-
|
|
5
|
-
export function elapsedLabel(time, now, zone = "Asia/Shanghai") {
|
|
6
|
-
const age = Math.max(0, now - time);
|
|
7
|
-
if (age < JUST_NOW) return "刚才";
|
|
8
|
-
const day = localClock(time, zone).local.slice(0, 10);
|
|
9
|
-
const today = localClock(now, zone).local.slice(0, 10);
|
|
10
|
-
if (day === today) return "今天早些时候";
|
|
11
|
-
if (day === localClock(now - 86400000, zone).local.slice(0, 10))
|
|
12
|
-
return "昨天";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (days <
|
|
20
|
-
if (days <
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1
|
+
import { localClock } from "../core/conversation-cues.js";
|
|
2
|
+
|
|
3
|
+
export const JUST_NOW = 5 * 60000;
|
|
4
|
+
|
|
5
|
+
export function elapsedLabel(time, now, zone = "Asia/Shanghai") {
|
|
6
|
+
const age = Math.max(0, now - time);
|
|
7
|
+
if (age < JUST_NOW) return "刚才";
|
|
8
|
+
const day = localClock(time, zone).local.slice(0, 10);
|
|
9
|
+
const today = localClock(now, zone).local.slice(0, 10);
|
|
10
|
+
if (day === today) return "今天早些时候";
|
|
11
|
+
if (day === localClock(now - 86400000, zone).local.slice(0, 10))
|
|
12
|
+
return "昨天";
|
|
13
|
+
const days = age / 86400000;
|
|
14
|
+
if (days < 7) return "好几天前";
|
|
15
|
+
// Nine days and two years ago should not read the same.
|
|
16
|
+
if (days < 14) return "一周前";
|
|
17
|
+
if (days < 30) return "两三周前";
|
|
18
|
+
if (days < 60) return "一个多月前";
|
|
19
|
+
if (days < 365) return "几个月前";
|
|
20
|
+
if (days < 730) return "一年多前";
|
|
21
|
+
return "很久以前";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Coarse distance in time, the way a person would say it.
|
|
25
|
+
export function agoLabel(ms) {
|
|
26
|
+
const days = Math.floor(Math.max(0, ms) / 86400000);
|
|
27
|
+
if (days < 1) return "今天";
|
|
28
|
+
if (days < 2) return "昨天";
|
|
29
|
+
if (days < 7) return `${days} 天前`;
|
|
30
|
+
if (days < 30) return `${Math.floor(days / 7)} 周前`;
|
|
31
|
+
if (days < 365) return `${Math.floor(days / 30)} 个月前`;
|
|
32
|
+
return `${Math.floor(days / 365)} 年前`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function topicWeight(
|
|
36
|
+
time,
|
|
37
|
+
now,
|
|
38
|
+
{ importance = 0, mentions = 1, open = false } = {},
|
|
39
|
+
) {
|
|
40
|
+
const halfLife = (open ? 48 : 6) * 3600000 * (1 + Math.min(1, importance));
|
|
41
|
+
return Math.min(
|
|
42
|
+
1,
|
|
43
|
+
Math.pow(0.5, Math.max(0, now - time) / halfLife) *
|
|
44
|
+
(1 + Math.min(3, mentions - 1) * 0.1),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function timePhase(lastTime, now) {
|
|
49
|
+
if (!lastTime)
|
|
50
|
+
return {
|
|
51
|
+
key: "unfamiliar",
|
|
52
|
+
label: "还未相遇",
|
|
53
|
+
description: "还没有真实聊天,时间暂时没有可以延续的落点。",
|
|
54
|
+
};
|
|
55
|
+
const age = Math.max(0, now - lastTime);
|
|
56
|
+
if (age < 30 * 60000)
|
|
57
|
+
return {
|
|
58
|
+
key: "present",
|
|
59
|
+
label: "仍在对话里",
|
|
60
|
+
description: "刚才的语气和话题仍然清晰,适合自然接着聊。",
|
|
61
|
+
};
|
|
62
|
+
if (age < 3 * 3600000)
|
|
63
|
+
return {
|
|
64
|
+
key: "afterglow",
|
|
65
|
+
label: "留有余韵",
|
|
66
|
+
description: "聊天刚安静下来,刚才没说完的事还会留在注意里。",
|
|
67
|
+
};
|
|
68
|
+
if (age < 12 * 3600000)
|
|
69
|
+
return {
|
|
70
|
+
key: "settled",
|
|
71
|
+
label: "各自生活",
|
|
72
|
+
description: "注意力已经离开即时对话,但重要的事仍可能被想起。",
|
|
73
|
+
};
|
|
74
|
+
if (age < 2 * 86400000)
|
|
75
|
+
return {
|
|
76
|
+
key: "quiet",
|
|
77
|
+
label: "安静了一阵",
|
|
78
|
+
description: "旧话题正在降温,未完成的事情比普通闲聊更容易被想起。",
|
|
79
|
+
};
|
|
80
|
+
if (age < 7 * 86400000)
|
|
81
|
+
return {
|
|
82
|
+
key: "remembering",
|
|
83
|
+
label: "偶尔想起",
|
|
84
|
+
description: "相隔已有几天,熟悉感还在,但重新开口需要具体缘由。",
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
key: "reunion",
|
|
88
|
+
label: "等待重逢",
|
|
89
|
+
description: "已经隔了很久,旧事只保留为背景,新的消息会成为新的相遇。",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// How a privately learned thing reaches her in a public place: she keeps the
|
|
2
|
+
// content and its source to herself. Keeping is not denying: pretending she
|
|
3
|
+
// never heard it would be a lie of its own.
|
|
4
|
+
export const PRIVATELY_KNOWN =
|
|
5
|
+
"私下知道的:当众不说内容,也不说从哪听来;有人追问就说这不该在这儿讲,不装作不知道";
|
|
6
|
+
|
|
7
|
+
// What she sees of a private thing in a place where she may not repeat it.
|
|
8
|
+
// The content stays out, but she is not left blind: the words that people
|
|
9
|
+
// here have already said aloud and that the thing is about are named, so she
|
|
10
|
+
// knows she does know, and can keep it rather than claim she never heard.
|
|
11
|
+
export function withheldMemory(content, said = []) {
|
|
12
|
+
const heard = new Set(said);
|
|
13
|
+
const shared = [...heard].filter(
|
|
14
|
+
(term) => term.length >= 2 && String(content).includes(term),
|
|
15
|
+
);
|
|
16
|
+
const topic = shared.slice(0, 3).join("、");
|
|
17
|
+
return topic
|
|
18
|
+
? `(关于此人的一件私下的事,和这里刚说到的「${topic}」有关;内容不在这里复述)`
|
|
19
|
+
: "(关于此人的一条受限记忆;没有在这里复述具体内容的授权)";
|
|
20
|
+
}
|
package/server/mind/episodes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PRIVATELY_KNOWN } from "./discretion.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { applySpeakerNames } from "../core/speaker-names.js";
|
|
3
4
|
import { elapsedLabel } from "./clock.js";
|
|
@@ -235,7 +236,7 @@ export class Episodes {
|
|
|
235
236
|
updated: elapsedLabel(last.created, now, zone),
|
|
236
237
|
...(last.basis === "stated" ? { basis: "对方告诉你的" } : {}),
|
|
237
238
|
...(!local && isPrivateSession(last.session_id)
|
|
238
|
-
? { discretion:
|
|
239
|
+
? { discretion: PRIVATELY_KNOWN }
|
|
239
240
|
: {}),
|
|
240
241
|
};
|
|
241
242
|
}
|
package/server/mind/index.js
CHANGED
|
@@ -87,6 +87,17 @@ const TURN_CHANGES = new Set(
|
|
|
87
87
|
),
|
|
88
88
|
);
|
|
89
89
|
|
|
90
|
+
// One entry per item of her past that was in front of her, first reason
|
|
91
|
+
// kept. Ids are recall index ids ("fact:…", "episode:…", "self:…").
|
|
92
|
+
function recalledLedger(items) {
|
|
93
|
+
const seen = new Map();
|
|
94
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
95
|
+
const id = String(item?.id || "");
|
|
96
|
+
if (id && !seen.has(id)) seen.set(id, String(item.why || "").slice(0, 24));
|
|
97
|
+
}
|
|
98
|
+
return [...seen].slice(0, 60).map(([id, why]) => ({ id, why }));
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
// One of her, across every conversation. Sessions only say where something
|
|
91
102
|
// happened; they are no longer walls between parts of her.
|
|
92
103
|
export class Mind {
|
|
@@ -94,6 +105,8 @@ export class Mind {
|
|
|
94
105
|
this.repo = repo;
|
|
95
106
|
this.db = repo.db;
|
|
96
107
|
this.store = repo.store;
|
|
108
|
+
// Her clock, so a tombstone sits on the same timeline as what it buries.
|
|
109
|
+
this.now = now;
|
|
97
110
|
migrateMind(this.db, this.store);
|
|
98
111
|
this.backoff = new Backoff(this.db);
|
|
99
112
|
this.embedding = new EmbeddingService(repo, models, { now });
|
|
@@ -186,11 +199,12 @@ export class Mind {
|
|
|
186
199
|
reason = "",
|
|
187
200
|
watermark = null,
|
|
188
201
|
occasion = null,
|
|
202
|
+
recalled = [],
|
|
189
203
|
time = Date.now(),
|
|
190
204
|
}) {
|
|
191
205
|
this.db
|
|
192
206
|
.prepare(
|
|
193
|
-
"INSERT INTO mind_choices(id,created,session_id,trace_id,choice,appraisal,reason,watermark,occasion) VALUES (
|
|
207
|
+
"INSERT INTO mind_choices(id,created,session_id,trace_id,choice,appraisal,reason,watermark,occasion,recalled) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
194
208
|
)
|
|
195
209
|
.run(
|
|
196
210
|
randomUUID(),
|
|
@@ -202,6 +216,7 @@ export class Mind {
|
|
|
202
216
|
text(reason, 300),
|
|
203
217
|
watermark,
|
|
204
218
|
occasion,
|
|
219
|
+
JSON.stringify(recalledLedger(recalled)),
|
|
205
220
|
);
|
|
206
221
|
}
|
|
207
222
|
choices({ session = "", limit = 30, before = Number.MAX_SAFE_INTEGER } = {}) {
|
|
@@ -337,7 +352,7 @@ export class Mind {
|
|
|
337
352
|
.prepare(
|
|
338
353
|
"INSERT INTO mind_revocations(id,created,target_kind,target_id,content,reason) VALUES (?,?,?,?,?,?)",
|
|
339
354
|
)
|
|
340
|
-
.run(randomUUID(),
|
|
355
|
+
.run(randomUUID(), this.now(), kind, id, content, note);
|
|
341
356
|
if (kind === "memory") return this.memory.revoke(id, note);
|
|
342
357
|
if (kind === "self") {
|
|
343
358
|
const rows = this.self.history(id);
|
|
@@ -83,9 +83,9 @@ export class Initiative {
|
|
|
83
83
|
if (!row.subject) return true;
|
|
84
84
|
const seen = this.db
|
|
85
85
|
.prepare(
|
|
86
|
-
"SELECT 1 FROM core_events WHERE session_id=? AND role='user' AND payload
|
|
86
|
+
"SELECT 1 FROM core_events WHERE session_id=? AND role='user' AND json_extract(payload,'$.userId')=? LIMIT 1",
|
|
87
87
|
)
|
|
88
|
-
.get(session,
|
|
88
|
+
.get(session, String(row.subject));
|
|
89
89
|
return !!seen;
|
|
90
90
|
})
|
|
91
91
|
.slice(0, 3)
|
|
@@ -322,6 +322,15 @@ export class Initiative {
|
|
|
322
322
|
}
|
|
323
323
|
return material;
|
|
324
324
|
}
|
|
325
|
+
recentUserWords(session, limit = 50) {
|
|
326
|
+
return this.db
|
|
327
|
+
.prepare(
|
|
328
|
+
"SELECT payload FROM core_events WHERE session_id=? AND role='user' AND seq NOT IN (SELECT seq FROM mind_unlived) AND COALESCE(json_extract(payload,'$.simulated'),0)=0 ORDER BY seq DESC LIMIT ?",
|
|
329
|
+
)
|
|
330
|
+
.all(session, limit)
|
|
331
|
+
.map((row) => parse(row.payload, {}).text || "")
|
|
332
|
+
.reverse();
|
|
333
|
+
}
|
|
325
334
|
blocked(session, now, settings) {
|
|
326
335
|
if (!session || !this.life.chat.enabled(session, { simulated: false }))
|
|
327
336
|
return { reason: "会话不可用", permanent: true };
|
|
@@ -332,18 +341,10 @@ export class Initiative {
|
|
|
332
341
|
reason: "对方没有开启主动消息,等对方来找她",
|
|
333
342
|
retryAt: now + 6 * HOUR,
|
|
334
343
|
};
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
.get(session);
|
|
340
|
-
const words = parse(lastUser?.payload, {}).text || "";
|
|
341
|
-
// Explicit contact boundaries are different from “this message needs no reply”.
|
|
342
|
-
if (
|
|
343
|
-
/(?:别|不要|不用)再?(?:主动)?(?:给我发消息|找我|联系我|私聊我)|别再打扰我/.test(
|
|
344
|
-
words,
|
|
345
|
-
)
|
|
346
|
-
)
|
|
344
|
+
// Explicit contact boundaries are different from “this message needs no
|
|
345
|
+
// reply”. A boundary stays after the person goes on talking about other
|
|
346
|
+
// things; only their own later invitation lifts it.
|
|
347
|
+
if (contactBoundary(this.recentUserWords(session)))
|
|
347
348
|
return { reason: "对方明确不想被主动联系", permanent: true };
|
|
348
349
|
if (this.life.chat.queue.lanes.has(session))
|
|
349
350
|
return { reason: "这段对话正在处理,稍后重新看看", retryAt: now + 60000 };
|
|
@@ -354,3 +355,17 @@ export class Initiative {
|
|
|
354
355
|
return null;
|
|
355
356
|
}
|
|
356
357
|
}
|
|
358
|
+
|
|
359
|
+
const BOUNDARY =
|
|
360
|
+
/(?:别|不要|不用)再?(?:主动)?(?:给我发消息|找我|联系我|私聊我)|别再打扰我/;
|
|
361
|
+
const INVITATION =
|
|
362
|
+
/(?:可以|随时|有空|想聊)(?:就)?(?:主动)?(?:找我|联系我|私聊我|给我发消息)|(?:你|您)(?:可以|能)?主动(?:找|联系)我/;
|
|
363
|
+
// Whether the latest word on being contacted, oldest first, is a boundary.
|
|
364
|
+
export function contactBoundary(words) {
|
|
365
|
+
let held = false;
|
|
366
|
+
for (const said of words || []) {
|
|
367
|
+
if (INVITATION.test(said)) held = false;
|
|
368
|
+
else if (BOUNDARY.test(said)) held = true;
|
|
369
|
+
}
|
|
370
|
+
return held;
|
|
371
|
+
}
|
package/server/mind/memory.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PRIVATELY_KNOWN, withheldMemory } from "./discretion.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
parseSessionKey,
|
|
@@ -52,7 +53,11 @@ function recallQuery(rows) {
|
|
|
52
53
|
const seen = new Set();
|
|
53
54
|
for (const row of [...rows].reverse()) {
|
|
54
55
|
if (row.role === "assistant") continue;
|
|
55
|
-
|
|
56
|
+
// The line a short follow-up leans on comes after its own words, so a
|
|
57
|
+
// long context never crowds the follow-up out of the term budget.
|
|
58
|
+
for (const term of lexicalTerms(
|
|
59
|
+
[row.text, row.context].filter(Boolean).join(" "),
|
|
60
|
+
))
|
|
56
61
|
if (!seen.has(term)) {
|
|
57
62
|
seen.add(term);
|
|
58
63
|
terms.push(term);
|
|
@@ -133,7 +138,10 @@ export class MemoryManager {
|
|
|
133
138
|
const id = String(row.userId || "");
|
|
134
139
|
if (!id) continue;
|
|
135
140
|
const terms = saidBy.get(id) || new Set();
|
|
136
|
-
for (const term of lexicalTerms(
|
|
141
|
+
for (const term of lexicalTerms(
|
|
142
|
+
[row.context, row.text].filter(Boolean).join(" "),
|
|
143
|
+
))
|
|
144
|
+
terms.add(term);
|
|
137
145
|
saidBy.set(id, terms);
|
|
138
146
|
}
|
|
139
147
|
const oneSpeakerOverlap = (content) => {
|
|
@@ -254,7 +262,9 @@ export class MemoryManager {
|
|
|
254
262
|
subject: m.subject,
|
|
255
263
|
content:
|
|
256
264
|
forSpeech && !this.permission(m, session, cutoff).disclose
|
|
257
|
-
?
|
|
265
|
+
? withheldMemory(m.content, [
|
|
266
|
+
...new Set([...saidBy.values()].flatMap((t) => [...t])),
|
|
267
|
+
])
|
|
258
268
|
: m.content,
|
|
259
269
|
type: m.type,
|
|
260
270
|
confidence: m.confidence,
|
|
@@ -268,11 +278,43 @@ export class MemoryManager {
|
|
|
268
278
|
// Old news may no longer be true; she should know how old it is.
|
|
269
279
|
...(age >= 7 * DAY ? { when: `${agoLabel(age)}知道的` } : {}),
|
|
270
280
|
...(m.discretion === "private" && !local
|
|
271
|
-
? { discretion:
|
|
281
|
+
? { discretion: PRIVATELY_KNOWN }
|
|
272
282
|
: {}),
|
|
283
|
+
...this.conflictNote(m, session, cutoff, forSpeech),
|
|
273
284
|
};
|
|
274
285
|
});
|
|
275
286
|
}
|
|
287
|
+
// Two statements marked as not fitting together, both still held: when one
|
|
288
|
+
// comes back, she sees the other beside it, with when and where she heard
|
|
289
|
+
// it, instead of taking either for settled.
|
|
290
|
+
conflictNote(m, session, now = Date.now(), forSpeech = false) {
|
|
291
|
+
const ref = `fact:${m.id}`;
|
|
292
|
+
const other = this.repo.db
|
|
293
|
+
.prepare(
|
|
294
|
+
`SELECT m.* FROM mind_links l JOIN core_memories m
|
|
295
|
+
ON 'fact:' || m.id = CASE WHEN l.from_id=? THEN l.to_id ELSE l.from_id END
|
|
296
|
+
WHERE l.relation='conflicts' AND (l.from_id=? OR l.to_id=?)
|
|
297
|
+
AND m.status='confirmed' AND (m.expires IS NULL OR m.expires>?)
|
|
298
|
+
ORDER BY m.created DESC LIMIT 1`,
|
|
299
|
+
)
|
|
300
|
+
.get(ref, ref, ref, now);
|
|
301
|
+
if (!other) return {};
|
|
302
|
+
const allowed = this.permission(other, session, now);
|
|
303
|
+
if (!allowed.recall) return {};
|
|
304
|
+
if (forSpeech && !allowed.disclose)
|
|
305
|
+
return { conflictsWith: "另有一条对不上的说法(受限,不在这里复述)" };
|
|
306
|
+
const age = now - Number(other.created || now);
|
|
307
|
+
const where =
|
|
308
|
+
this.scopes(session).includes(other.session_id) ||
|
|
309
|
+
other.session_id === "__shared__"
|
|
310
|
+
? "这里"
|
|
311
|
+
: isPrivateSession(other.session_id)
|
|
312
|
+
? "私聊里"
|
|
313
|
+
: "别的群里";
|
|
314
|
+
return {
|
|
315
|
+
conflictsWith: `另一个对不上的说法:「${String(other.content).slice(0, 60)}」(${age >= DAY ? `${agoLabel(age)},` : ""}${where}听到的;哪个现在成立还不确定)`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
276
318
|
secretsOutside(session) {
|
|
277
319
|
return this.#outside(session, "secret");
|
|
278
320
|
}
|
|
@@ -414,7 +456,7 @@ export class MemoryManager {
|
|
|
414
456
|
)
|
|
415
457
|
.run(
|
|
416
458
|
randomUUID(),
|
|
417
|
-
Date.now(),
|
|
459
|
+
this.mind?.now?.() ?? Date.now(),
|
|
418
460
|
"memory",
|
|
419
461
|
id,
|
|
420
462
|
`${row.subject}\n${row.content}`,
|