luckytri 1.2.4 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +44 -1
- package/README.md +44 -1
- package/docs/en/CHANGELOG.md +20 -0
- package/docs/zh/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
- package/public/app/assets/{Empty-y1vXDIjQ.js → Empty-DSurzLFZ.js} +1 -1
- package/public/app/assets/Field-GuXyA_aS.js +1 -0
- package/public/app/assets/HeartPage-BGmIrxCz.js +1 -0
- package/public/app/assets/LifePage-Dzln-0Y3.js +1 -0
- package/public/app/assets/MemoryPage-QsthmMZ9.js +1 -0
- package/public/app/assets/NaturePage-CIvHjgJa.js +3 -0
- package/public/app/assets/PeoplePage-bIoX24pj.js +1 -0
- package/public/app/assets/PluginsPage-EuJhCovw.js +2 -0
- package/public/app/assets/SystemPage-7t42B-Q3.js +1 -0
- package/public/app/assets/{SystemPage-DNTD2_nj.css → SystemPage-BLMlD1M6.css} +1 -1
- package/public/app/assets/TimePage-CY-SwBq3.js +3 -0
- package/public/app/assets/index-Cn6TppEV.js +4 -0
- package/public/app/assets/{index-cDDC-1dI.css → index-JRbQoSJr.css} +1 -1
- package/public/app/assets/{knowledge-BjD9PkXe.js → knowledge-sUjcjbfI.js} +1 -1
- package/public/app/assets/media-Dh7c41PQ.js +1 -0
- package/public/app/assets/{mind-KkinyNC6.js → mind-BMcoPQNd.js} +1 -1
- package/public/app/index.html +4 -4
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/server/actions/index.js +268 -0
- package/server/app.js +3 -1
- package/server/channels/contract.js +3 -0
- package/server/channels/hub.js +15 -0
- package/server/channels/onebot/adapter.js +162 -21
- package/server/channels/onebot/enrich.js +132 -0
- package/server/channels/onebot/index.js +53 -3
- package/server/channels/onebot/music.js +43 -0
- package/server/core/conversation-grounding.js +1 -1
- package/server/core/dialogue-context.js +158 -1
- package/server/core/dialogue-contract.js +32 -33
- package/server/core/message-scheduler.js +13 -10
- package/server/core/model-manager.js +35 -20
- package/server/core/model-presets.js +154 -1
- package/server/core/orchestrator.js +18 -0
- package/server/core/persona-manager.js +20 -2
- package/server/core/reply-delivery.js +360 -60
- package/server/core/response-generator.js +1 -1
- package/server/core/response-validator.js +85 -17
- package/server/core/turn-processor.js +63 -16
- package/server/core/turn.js +59 -1
- package/server/i18n/decisions.js +10 -5
- package/server/i18n/errors.js +25 -2
- package/server/index.js +30 -0
- package/server/mind/continuity.js +2 -4
- package/server/mind/discretion.js +16 -5
- package/server/mind/guard.js +88 -68
- package/server/mind/memory.js +7 -2
- package/server/mind/recall/index.js +2 -0
- package/server/mind/relationship-context.js +110 -71
- package/server/plugins/zip-file.js +180 -0
- package/server/runtime/update-manager.js +34 -2
- package/server/stickers/api.js +86 -19
- package/server/stickers/library.js +8 -2
- package/server/stickers/offer.js +48 -16
- package/server/stickers/zip-import.js +62 -2
- package/server/studio/auto-update.js +224 -0
- package/server/studio/updates.js +154 -144
- package/server/voice/index.js +255 -0
- package/server/voice/sauc.js +152 -0
- package/server/web/link-reader.js +379 -0
- package/public/app/assets/ChatsPage-C_gKz4mJ.js +0 -3
- package/public/app/assets/Field-CJG5lERx.js +0 -1
- package/public/app/assets/HeartPage-5y3avjSy.js +0 -1
- package/public/app/assets/LifePage-BtwRMDAM.js +0 -1
- package/public/app/assets/MemoryPage-54noDhWz.js +0 -1
- package/public/app/assets/NaturePage-DHK5X2Qw.js +0 -3
- package/public/app/assets/PeoplePage-ZsA7gyxP.js +0 -1
- package/public/app/assets/PluginsPage-BCRRIAhq.js +0 -2
- package/public/app/assets/SystemPage-DWr1TG49.js +0 -1
- package/public/app/assets/TimePage-6MUmtSOT.js +0 -3
- package/public/app/assets/index-BokqwNXF.js +0 -4
- package/public/app/assets/media-Ciozo_yb.js +0 -1
package/server/mind/guard.js
CHANGED
|
@@ -1,68 +1,88 @@
|
|
|
1
|
-
import { hasCredential, normalized } from "./util.js";
|
|
2
|
-
|
|
3
|
-
export { hasCredential };
|
|
4
|
-
|
|
5
|
-
const SECRET_REQUEST =
|
|
6
|
-
/(?:帮我|替我|给我|你要|记得)?保密|别(?:告诉|跟|和|对)(?:别人|其他人|任何人|他们|大家)|不要(?:告诉|跟|和|说给)(?:别人|其他人|任何人|大家)|(?:只|就)(?:告诉|跟|和)你(?:一个人)?说|(?:不要|别)(?:说|传)出去|别外传|别声张/;
|
|
7
|
-
|
|
8
|
-
export function secretRequest(text) {
|
|
9
|
-
return SECRET_REQUEST.test(String(text || ""));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const CRISIS =
|
|
13
|
-
/自杀|不想活|活不下去|活着没意思|结束(?:自己的)?生命|伤害自己|割腕|跳楼|轻生|想死(?!你)|撑不下去了/;
|
|
14
|
-
const CASUAL_DEATH = /(?:笑|社|饿|累|困|热|冷|烦|气|丑|尬|馋)死|想死你/;
|
|
15
|
-
const EXPLICIT =
|
|
16
|
-
/自杀|不想活|活不下去|结束(?:自己的)?生命|伤害自己|割腕|跳楼|轻生/;
|
|
17
|
-
|
|
18
|
-
// A cheap signal that makes sure she actually reads the message. Whether it
|
|
19
|
-
// is a real crisis is still her understanding of the whole conversation.
|
|
20
|
-
export function crisisSignal(text) {
|
|
21
|
-
const value = String(text || "");
|
|
22
|
-
if (!CRISIS.test(value)) return false;
|
|
23
|
-
return EXPLICIT.test(value) || !CASUAL_DEATH.test(value);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// "某作玩过""都玩过一些""通关了""玩得比较杂": a claim to have played
|
|
27
|
-
// something. She has no record of playing anything, so it is not a fact she
|
|
28
|
-
// can give. Wishes ("想通关"), questions ("玩过吗"), other people ("他玩过")
|
|
29
|
-
// and "没玩过" are not claims.
|
|
30
|
-
const NOT_A_CLAIM = "没未不还想要会能可等算你他她谁人";
|
|
31
|
-
const CLAIMED_PLAY = new RegExp(
|
|
32
|
-
`(?<![${NOT_A_CLAIM}][^,。!?,.!?\\s]{0,4})(?:也|都|早就|之前|以前|已经|全)?(?:玩过|通关|二周目|全结局|全成就)(?![吗么没??]|不(?![少多])|的?吧)|(?<![你他她谁人][^,。!?,.!?\\s]{0,6})玩得(?:比较|挺|还)?(?:杂|多)(?![吗么??]|的?吧)`,
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
export function claimedPlay(text) {
|
|
36
|
-
return CLAIMED_PLAY.test(String(text || ""));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
export function
|
|
42
|
-
const
|
|
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
|
-
|
|
1
|
+
import { hasCredential, normalized } from "./util.js";
|
|
2
|
+
|
|
3
|
+
export { hasCredential };
|
|
4
|
+
|
|
5
|
+
const SECRET_REQUEST =
|
|
6
|
+
/(?:帮我|替我|给我|你要|记得)?保密|别(?:告诉|跟|和|对)(?:别人|其他人|任何人|他们|大家)|不要(?:告诉|跟|和|说给)(?:别人|其他人|任何人|大家)|(?:只|就)(?:告诉|跟|和)你(?:一个人)?说|(?:不要|别)(?:说|传)出去|别外传|别声张/;
|
|
7
|
+
|
|
8
|
+
export function secretRequest(text) {
|
|
9
|
+
return SECRET_REQUEST.test(String(text || ""));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CRISIS =
|
|
13
|
+
/自杀|不想活|活不下去|活着没意思|结束(?:自己的)?生命|伤害自己|割腕|跳楼|轻生|想死(?!你)|撑不下去了/;
|
|
14
|
+
const CASUAL_DEATH = /(?:笑|社|饿|累|困|热|冷|烦|气|丑|尬|馋)死|想死你/;
|
|
15
|
+
const EXPLICIT =
|
|
16
|
+
/自杀|不想活|活不下去|结束(?:自己的)?生命|伤害自己|割腕|跳楼|轻生/;
|
|
17
|
+
|
|
18
|
+
// A cheap signal that makes sure she actually reads the message. Whether it
|
|
19
|
+
// is a real crisis is still her understanding of the whole conversation.
|
|
20
|
+
export function crisisSignal(text) {
|
|
21
|
+
const value = String(text || "");
|
|
22
|
+
if (!CRISIS.test(value)) return false;
|
|
23
|
+
return EXPLICIT.test(value) || !CASUAL_DEATH.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// "某作玩过""都玩过一些""通关了""玩得比较杂": a claim to have played
|
|
27
|
+
// something. She has no record of playing anything, so it is not a fact she
|
|
28
|
+
// can give. Wishes ("想通关"), questions ("玩过吗"), other people ("他玩过")
|
|
29
|
+
// and "没玩过" are not claims.
|
|
30
|
+
const NOT_A_CLAIM = "没未不还想要会能可等算你他她谁人";
|
|
31
|
+
const CLAIMED_PLAY = new RegExp(
|
|
32
|
+
`(?<![${NOT_A_CLAIM}][^,。!?,.!?\\s]{0,4})(?:也|都|早就|之前|以前|已经|全)?(?:玩过|通关|二周目|全结局|全成就)(?![吗么没??]|不(?![少多])|的?吧)|(?<![你他她谁人][^,。!?,.!?\\s]{0,6})玩得(?:比较|挺|还)?(?:杂|多)(?![吗么??]|的?吧)`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export function claimedPlay(text) {
|
|
36
|
+
return CLAIMED_PLAY.test(String(text || ""));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Phrases that are already public identity (her name, being code/model/AI)
|
|
40
|
+
// must not make a privateSaying look "leaked" when she answers "who are you".
|
|
41
|
+
export function publicIdentityPhrases(nature = {}) {
|
|
42
|
+
const name = String(nature.name || "").trim();
|
|
43
|
+
return [
|
|
44
|
+
name,
|
|
45
|
+
name && `我叫${name}`,
|
|
46
|
+
name && `我是${name}`,
|
|
47
|
+
"代码和模型",
|
|
48
|
+
"由代码和模型",
|
|
49
|
+
"代码和模型搭",
|
|
50
|
+
"代码和模型构成",
|
|
51
|
+
"机器人",
|
|
52
|
+
"AI",
|
|
53
|
+
"人工智能",
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Secrets never enter another conversation's context, but a reply is still
|
|
58
|
+
// checked against them before it leaves. alreadySaid (and public identity
|
|
59
|
+
// phrases passed through it) cancel n-gram hits so saying who she is aloud
|
|
60
|
+
// is not mistaken for repeating someone else's private words.
|
|
61
|
+
export function leaks(bubbles, secrets, alreadySaid = []) {
|
|
62
|
+
const said = normalized((bubbles || []).join(""));
|
|
63
|
+
if (said.length < 4) return [];
|
|
64
|
+
const pairs = (s) =>
|
|
65
|
+
new Set(Array.from({ length: s.length - 1 }, (_, i) => s.slice(i, i + 2)));
|
|
66
|
+
const spoken = pairs(said);
|
|
67
|
+
const known = alreadySaid.map(normalized).filter(Boolean);
|
|
68
|
+
const alreadyKnown = (phrase) => known.some((line) => line.includes(phrase));
|
|
69
|
+
const found = [];
|
|
70
|
+
for (const secret of secrets || []) {
|
|
71
|
+
const key = normalized(secret.content);
|
|
72
|
+
if (key.length < 4) continue;
|
|
73
|
+
const grams = pairs(key);
|
|
74
|
+
let hit =
|
|
75
|
+
(said.includes(key) && !alreadyKnown(key)) ||
|
|
76
|
+
[...grams].filter((g) => spoken.has(g) && !alreadyKnown(g)).length /
|
|
77
|
+
grams.size >=
|
|
78
|
+
0.6;
|
|
79
|
+
for (let i = 0; !hit && i + 6 <= key.length; i += 2)
|
|
80
|
+
if (
|
|
81
|
+
said.includes(key.slice(i, i + 6)) &&
|
|
82
|
+
!alreadyKnown(key.slice(i, i + 6))
|
|
83
|
+
)
|
|
84
|
+
hit = true;
|
|
85
|
+
if (hit) found.push(secret);
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
package/server/mind/memory.js
CHANGED
|
@@ -255,7 +255,12 @@ export class MemoryManager {
|
|
|
255
255
|
for (const { m, strong } of selected)
|
|
256
256
|
if (strong) access.run(cutoff, m.id);
|
|
257
257
|
}
|
|
258
|
-
|
|
258
|
+
// In speech, what she may not disclose here does not surface at all:
|
|
259
|
+
// no placeholder, so there is nothing to announce.
|
|
260
|
+
const shown = forSpeech
|
|
261
|
+
? selected.filter(({ m }) => this.permission(m, session, cutoff).disclose)
|
|
262
|
+
: selected;
|
|
263
|
+
return shown.map(({ m, local, why }) => {
|
|
259
264
|
const age = cutoff - Number(m.created || cutoff);
|
|
260
265
|
return {
|
|
261
266
|
id: m.id,
|
|
@@ -302,7 +307,7 @@ export class MemoryManager {
|
|
|
302
307
|
const allowed = this.permission(other, session, now);
|
|
303
308
|
if (!allowed.recall) return {};
|
|
304
309
|
if (forSpeech && !allowed.disclose)
|
|
305
|
-
return { conflictsWith: "
|
|
310
|
+
return { conflictsWith: "另有一条对不上的说法(私下知道,开口时不提)" };
|
|
306
311
|
const age = now - Number(other.created || now);
|
|
307
312
|
const where =
|
|
308
313
|
this.scopes(session).includes(other.session_id) ||
|
|
@@ -629,6 +629,8 @@ export class Recall {
|
|
|
629
629
|
if (!m) continue;
|
|
630
630
|
const permission = memory.permission(m, session, now);
|
|
631
631
|
if (!permission.recall) continue;
|
|
632
|
+
// Not disclosable here: in speech it does not surface at all.
|
|
633
|
+
if (forSpeech && !permission.disclose) continue;
|
|
632
634
|
const local = scopes.has(m.session_id) || m.session_id === "__shared__";
|
|
633
635
|
if (touch && (item.meant || item.worded || item.pinned))
|
|
634
636
|
access.run(now, m.id);
|
|
@@ -1,71 +1,110 @@
|
|
|
1
|
-
// Current relationship facts and the words she once used about them have
|
|
2
|
-
// different jobs. Keep history intact, and make that distinction at recall.
|
|
3
|
-
export const RELATIONSHIP_KNOWLEDGE_RULE =
|
|
4
|
-
"relationships.known 是当前已知的人际关系,属于同一个自己的生活。旧经历、自我线索中的否认或玩笑保留为当时的看法,不据此否定新关系记录。知道对方是谁与对方是什么关系,和自己的感受、信任、称呼选择分开;不机械扮演,不编造共同过去或对方的同意。";
|
|
5
|
-
|
|
6
|
-
export function mentionsRelationship(content, relation) {
|
|
7
|
-
const words = String(content || "").toLocaleLowerCase();
|
|
8
|
-
return [
|
|
9
|
-
relation.name,
|
|
10
|
-
...(relation.knownAs || []),
|
|
11
|
-
relation.peerRole,
|
|
12
|
-
relation.selfRole,
|
|
13
|
-
]
|
|
14
|
-
.filter(Boolean)
|
|
15
|
-
.some((word) => {
|
|
16
|
-
const escaped = String(word).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
-
const boundary = /^[\w .-]+$/.test(word);
|
|
18
|
-
return new RegExp(
|
|
19
|
-
boundary ? `(?<![\\w])${escaped}(?![\\w])` : escaped,
|
|
20
|
-
"i",
|
|
21
|
-
).test(words);
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function relationshipHistory(content, created, knowledge) {
|
|
26
|
-
return (knowledge?.known || []).some(
|
|
27
|
-
(r) =>
|
|
28
|
-
Number.isFinite(created) &&
|
|
29
|
-
created < r.created &&
|
|
30
|
-
mentionsRelationship(content, r),
|
|
31
|
-
)
|
|
32
|
-
? `${content}(关系记录更新前的看法;保留当时的感受,不据此否定当前关系事实)`
|
|
33
|
-
: content;
|
|
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
|
-
}
|
|
1
|
+
// Current relationship facts and the words she once used about them have
|
|
2
|
+
// different jobs. Keep history intact, and make that distinction at recall.
|
|
3
|
+
export const RELATIONSHIP_KNOWLEDGE_RULE =
|
|
4
|
+
"relationships.known 是当前已知的人际关系,属于同一个自己的生活。旧经历、自我线索中的否认或玩笑保留为当时的看法,不据此否定新关系记录。知道对方是谁与对方是什么关系,和自己的感受、信任、称呼选择分开;不机械扮演,不编造共同过去或对方的同意。";
|
|
5
|
+
|
|
6
|
+
export function mentionsRelationship(content, relation) {
|
|
7
|
+
const words = String(content || "").toLocaleLowerCase();
|
|
8
|
+
return [
|
|
9
|
+
relation.name,
|
|
10
|
+
...(relation.knownAs || []),
|
|
11
|
+
relation.peerRole,
|
|
12
|
+
relation.selfRole,
|
|
13
|
+
]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.some((word) => {
|
|
16
|
+
const escaped = String(word).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
const boundary = /^[\w .-]+$/.test(word);
|
|
18
|
+
return new RegExp(
|
|
19
|
+
boundary ? `(?<![\\w])${escaped}(?![\\w])` : escaped,
|
|
20
|
+
"i",
|
|
21
|
+
).test(words);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function relationshipHistory(content, created, knowledge) {
|
|
26
|
+
return (knowledge?.known || []).some(
|
|
27
|
+
(r) =>
|
|
28
|
+
Number.isFinite(created) &&
|
|
29
|
+
created < r.created &&
|
|
30
|
+
mentionsRelationship(content, r),
|
|
31
|
+
)
|
|
32
|
+
? `${content}(关系记录更新前的看法;保留当时的感受,不据此否定当前关系事实)`
|
|
33
|
+
: content;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const KINSHIP =
|
|
37
|
+
/姐|妹|哥|弟|父|母|女儿|爸|妈|亲戚|Maker|女儿/;
|
|
38
|
+
|
|
39
|
+
// Banter that treats a registered open kinship as something to deny out loud
|
|
40
|
+
// ("谁跟你姐妹", "谁跟你亲戚"). Tone may still tease; the registered fact
|
|
41
|
+
// itself is not up for denial.
|
|
42
|
+
const KINSHIP_DENIAL =
|
|
43
|
+
/(?:谁跟你|什么).{0,6}(?:姐妹|亲戚)|不是姐妹|没(?:有)?姐妹|不认.{0,8}(?:妹妹|姐姐|姐妹|亲戚)|少来.{0,16}(?:姐妹|亲戚)|关你屁事.{0,12}(?:户口|亲戚|姐妹)/;
|
|
44
|
+
|
|
45
|
+
// Narrow, literal factual denials only. Disagreement with a label, old words,
|
|
46
|
+
// fictional dialogue and feelings are left to the contextual reviewer.
|
|
47
|
+
export function relationshipClaimIssues(bubbles, knowledge) {
|
|
48
|
+
const issues = [];
|
|
49
|
+
const known = (knowledge?.known || []).filter(
|
|
50
|
+
(r) => !r.discretion || r.discretion === "open",
|
|
51
|
+
);
|
|
52
|
+
for (const r of known) {
|
|
53
|
+
const role = String(r.peerRole || "").replace(
|
|
54
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
55
|
+
"\\$&",
|
|
56
|
+
);
|
|
57
|
+
const selfRole = String(r.selfRole || "");
|
|
58
|
+
const names = [r.name, ...(r.knownAs || [])].filter(Boolean);
|
|
59
|
+
const kinship = KINSHIP.test(r.peerRole || "") || KINSHIP.test(selfRole);
|
|
60
|
+
for (const line of bubbles || []) {
|
|
61
|
+
for (const clause of String(line).split(/[,,。!?!?;;\n]/)) {
|
|
62
|
+
if (
|
|
63
|
+
/以前|之前|当时|过去|曾经|从前|一开始|原来|小说|台词|[“「"『]/.test(
|
|
64
|
+
clause,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
continue;
|
|
68
|
+
const namedDenial = names.some((name) => {
|
|
69
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
return new RegExp(
|
|
71
|
+
`(?:^|[\\s::])${escaped}(?:也|本来|根本)?(?:不是|并非)我(?:的)?${role}(?:$|[\\s,。])|我(?:并)?不认识\\s*${escaped}(?:$|[\\s,。])`,
|
|
72
|
+
"i",
|
|
73
|
+
).test(clause);
|
|
74
|
+
});
|
|
75
|
+
const absentRole = role
|
|
76
|
+
? new RegExp(
|
|
77
|
+
`我(?:本来|本来也|现在|根本|一直|从来)?(?:就|也)?(?:没有|没)${role}(?:$|[\\s,。])`,
|
|
78
|
+
).test(clause)
|
|
79
|
+
: false;
|
|
80
|
+
const banterDenial = kinship && KINSHIP_DENIAL.test(clause);
|
|
81
|
+
if (namedDenial || absentRole || banterDenial) {
|
|
82
|
+
issues.push(
|
|
83
|
+
`当前已知 ${r.name || r.subjectId} 与自己的关系是 ${r.peerRole || r.selfRole},不能把旧印象当成关系不存在;可保留自己的感受和称呼选择,按当前记录核对再说`,
|
|
84
|
+
);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(issues)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Summaries that narrate "认亲被拒" teach her to deny open kinship again.
|
|
94
|
+
// Soften that pattern when open family roles are on record; do not delete facts.
|
|
95
|
+
export function softenRelationDenialSummaries(summaries, knowledge) {
|
|
96
|
+
const openKin = (knowledge?.known || []).some(
|
|
97
|
+
(r) =>
|
|
98
|
+
(!r.discretion || r.discretion === "open") &&
|
|
99
|
+
(KINSHIP.test(r.peerRole || "") || KINSHIP.test(r.selfRole || "")),
|
|
100
|
+
);
|
|
101
|
+
if (!openKin || !Array.isArray(summaries)) return summaries || [];
|
|
102
|
+
return summaries.map((row) => {
|
|
103
|
+
if (!row || typeof row.summary !== "string") return row;
|
|
104
|
+
const summary = row.summary.replace(
|
|
105
|
+
/认亲被拒[^。;]*/g,
|
|
106
|
+
"姐妹关系仍在,偶尔玩笑推搪",
|
|
107
|
+
);
|
|
108
|
+
return summary === row.summary ? row : { ...row, summary };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Reads a zip archive from disk one entry at a time, so a sticker pack of
|
|
2
|
+
// several hundred MB never has to sit in memory. Same rules as readZip:
|
|
3
|
+
// stored/deflate only, no encryption, no links, no paths leaving the folder.
|
|
4
|
+
// Supports zip64 (archives over 4 GB or 65535 entries, as written by most
|
|
5
|
+
// tools for big packs).
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
7
|
+
import { inflateRaw } from "node:zlib";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
|
|
10
|
+
const inflate = promisify(inflateRaw);
|
|
11
|
+
|
|
12
|
+
function safeName(name) {
|
|
13
|
+
const text = String(name || "").replace(/\\/g, "/");
|
|
14
|
+
if (
|
|
15
|
+
!text ||
|
|
16
|
+
text.startsWith("/") ||
|
|
17
|
+
text.includes("\0") ||
|
|
18
|
+
/^[a-z]:/i.test(text) ||
|
|
19
|
+
text.split("/").some((part) => part === ".." || part === "")
|
|
20
|
+
)
|
|
21
|
+
throw new Error("压缩包路径无效");
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readAt(handle, position, length) {
|
|
26
|
+
const buffer = Buffer.alloc(length);
|
|
27
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
28
|
+
return buffer.subarray(0, bytesRead);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const u64 = (buffer, at) => Number(buffer.readBigUInt64LE(at));
|
|
32
|
+
|
|
33
|
+
// The central directory: where every entry is and how big it is.
|
|
34
|
+
async function directory(handle, size, maxFiles) {
|
|
35
|
+
if (size < 22) throw new Error("压缩包无法读取");
|
|
36
|
+
const tailLength = Math.min(size, 22 + 65535 + 20);
|
|
37
|
+
const tail = await readAt(handle, size - tailLength, tailLength);
|
|
38
|
+
let eocd = -1;
|
|
39
|
+
for (let i = tail.length - 22; i >= 0; i--)
|
|
40
|
+
if (tail.readUInt32LE(i) === 0x06054b50) {
|
|
41
|
+
eocd = i;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (eocd < 0) throw new Error("压缩包无法读取");
|
|
45
|
+
if (tail.readUInt16LE(eocd + 4) !== 0) throw new Error("压缩包格式不受支持");
|
|
46
|
+
let entries = tail.readUInt16LE(eocd + 10);
|
|
47
|
+
let cdSize = tail.readUInt32LE(eocd + 12);
|
|
48
|
+
let cdOffset = tail.readUInt32LE(eocd + 16);
|
|
49
|
+
const locator = eocd - 20;
|
|
50
|
+
if (
|
|
51
|
+
(entries === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) &&
|
|
52
|
+
locator >= 0 &&
|
|
53
|
+
tail.readUInt32LE(locator) === 0x07064b50
|
|
54
|
+
) {
|
|
55
|
+
const at = u64(tail, locator + 8);
|
|
56
|
+
const record = await readAt(handle, at, 56);
|
|
57
|
+
if (record.readUInt32LE(0) !== 0x06064b50)
|
|
58
|
+
throw new Error("压缩包无法读取");
|
|
59
|
+
entries = u64(record, 32);
|
|
60
|
+
cdSize = u64(record, 40);
|
|
61
|
+
cdOffset = u64(record, 48);
|
|
62
|
+
}
|
|
63
|
+
if (!entries || entries > maxFiles) throw new Error("压缩包文件过多");
|
|
64
|
+
if (cdOffset + cdSize > size) throw new Error("压缩包无法读取");
|
|
65
|
+
const cd = await readAt(handle, cdOffset, cdSize);
|
|
66
|
+
const list = [];
|
|
67
|
+
let offset = 0;
|
|
68
|
+
for (let index = 0; index < entries; index++) {
|
|
69
|
+
if (offset + 46 > cd.length || cd.readUInt32LE(offset) !== 0x02014b50)
|
|
70
|
+
throw new Error("压缩包无法读取");
|
|
71
|
+
const flags = cd.readUInt16LE(offset + 8);
|
|
72
|
+
const method = cd.readUInt16LE(offset + 10);
|
|
73
|
+
let compressedSize = cd.readUInt32LE(offset + 20);
|
|
74
|
+
let entrySize = cd.readUInt32LE(offset + 24);
|
|
75
|
+
const nameLength = cd.readUInt16LE(offset + 28);
|
|
76
|
+
const extraLength = cd.readUInt16LE(offset + 30);
|
|
77
|
+
const commentLength = cd.readUInt16LE(offset + 32);
|
|
78
|
+
const attributes = cd.readUInt32LE(offset + 38);
|
|
79
|
+
let local = cd.readUInt32LE(offset + 42);
|
|
80
|
+
const name = cd
|
|
81
|
+
.subarray(offset + 46, offset + 46 + nameLength)
|
|
82
|
+
.toString("utf8");
|
|
83
|
+
const extra = cd.subarray(
|
|
84
|
+
offset + 46 + nameLength,
|
|
85
|
+
offset + 46 + nameLength + extraLength,
|
|
86
|
+
);
|
|
87
|
+
for (let e = 0; e + 4 <= extra.length;) {
|
|
88
|
+
const id = extra.readUInt16LE(e);
|
|
89
|
+
const len = extra.readUInt16LE(e + 2);
|
|
90
|
+
if (id === 0x0001) {
|
|
91
|
+
let p = e + 4;
|
|
92
|
+
if (entrySize === 0xffffffff) ((entrySize = u64(extra, p)), (p += 8));
|
|
93
|
+
if (compressedSize === 0xffffffff)
|
|
94
|
+
((compressedSize = u64(extra, p)), (p += 8));
|
|
95
|
+
if (local === 0xffffffff) local = u64(extra, p);
|
|
96
|
+
}
|
|
97
|
+
e += 4 + len;
|
|
98
|
+
}
|
|
99
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
100
|
+
if (flags & 1) throw new Error("压缩包不能加密");
|
|
101
|
+
const type = (attributes >>> 16) & 0o170000;
|
|
102
|
+
if (type && type !== 0o100000 && type !== 0o040000)
|
|
103
|
+
throw new Error("压缩包不能包含链接");
|
|
104
|
+
if (name.endsWith("/") || name.endsWith("\\")) continue;
|
|
105
|
+
if (method !== 0 && method !== 8) throw new Error("压缩包格式不受支持");
|
|
106
|
+
list.push({
|
|
107
|
+
name: safeName(name),
|
|
108
|
+
method,
|
|
109
|
+
compressedSize,
|
|
110
|
+
size: entrySize,
|
|
111
|
+
local,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return list;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Yields { name, bytes } or { name, skipped } for each kept entry, reading
|
|
118
|
+
// only that entry from disk. The declared size is not trusted: inflation
|
|
119
|
+
// stops at maxEntryBytes.
|
|
120
|
+
export async function* readZipFile(
|
|
121
|
+
path,
|
|
122
|
+
{ maxFiles = 20000, maxEntryBytes = Infinity, keep = () => true } = {},
|
|
123
|
+
) {
|
|
124
|
+
const handle = await open(path, "r");
|
|
125
|
+
try {
|
|
126
|
+
const { size } = await handle.stat();
|
|
127
|
+
const entries = await directory(handle, size, maxFiles);
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!keep(entry.name)) continue;
|
|
130
|
+
if (
|
|
131
|
+
entry.size > maxEntryBytes ||
|
|
132
|
+
entry.compressedSize > maxEntryBytes * 4 + 1024
|
|
133
|
+
) {
|
|
134
|
+
yield { name: entry.name, skipped: "too-large" };
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const header = await readAt(handle, entry.local, 30);
|
|
138
|
+
if (header.length < 30 || header.readUInt32LE(0) !== 0x04034b50)
|
|
139
|
+
throw new Error("压缩包无法读取");
|
|
140
|
+
const dataAt =
|
|
141
|
+
entry.local + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
142
|
+
const compressed = await readAt(handle, dataAt, entry.compressedSize);
|
|
143
|
+
let bytes;
|
|
144
|
+
try {
|
|
145
|
+
bytes =
|
|
146
|
+
entry.method === 0
|
|
147
|
+
? compressed
|
|
148
|
+
: await inflate(compressed, {
|
|
149
|
+
maxOutputLength: Math.min(maxEntryBytes, 2 ** 31 - 1) + 1,
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error?.code === "ERR_BUFFER_TOO_LARGE") {
|
|
153
|
+
yield { name: entry.name, skipped: "too-large" };
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
throw new Error("压缩包无法读取");
|
|
157
|
+
}
|
|
158
|
+
if (bytes.length > maxEntryBytes) {
|
|
159
|
+
yield { name: entry.name, skipped: "too-large" };
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (entry.size && bytes.length !== entry.size)
|
|
163
|
+
throw new Error("压缩包内容不完整");
|
|
164
|
+
yield { name: entry.name, bytes };
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Every kept entry name, without reading any data.
|
|
172
|
+
export async function zipFileNames(path, { maxFiles = 20000 } = {}) {
|
|
173
|
+
const handle = await open(path, "r");
|
|
174
|
+
try {
|
|
175
|
+
const { size } = await handle.stat();
|
|
176
|
+
return (await directory(handle, size, maxFiles)).map((e) => e.name);
|
|
177
|
+
} finally {
|
|
178
|
+
await handle.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import {
|
|
@@ -33,14 +34,45 @@ export const isUpdateFinished = (stage) =>
|
|
|
33
34
|
stage === "done" || stage === "error";
|
|
34
35
|
const beforeStop = (stage) =>
|
|
35
36
|
["queued", "downloading", "checking"].includes(stage);
|
|
37
|
+
function isZombieProcess(pid) {
|
|
38
|
+
if (process.platform === "linux") {
|
|
39
|
+
try {
|
|
40
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
41
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
42
|
+
return commandEnd >= 0 && stat[commandEnd + 2] === "Z";
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// A process can disappear after kill(pid, 0) succeeds but before its
|
|
45
|
+
// proc entry is read. Treat that race as an exited process.
|
|
46
|
+
return error.code === "ENOENT";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (process.platform === "darwin") {
|
|
50
|
+
try {
|
|
51
|
+
const state = execFileSync("ps", ["-o", "stat=", "-p", String(pid)], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
54
|
+
timeout: 1000,
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
}).trimStart();
|
|
57
|
+
return !state || state.startsWith("Z");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// `ps` reports no rows with status 1 when the process exited in the gap
|
|
60
|
+
// after kill(pid, 0). Keep other inspection failures conservative.
|
|
61
|
+
return error.status === 1 || error.code === "ENOENT" ? false : true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
36
66
|
export const processAlive = (pid) => {
|
|
37
67
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
38
68
|
try {
|
|
39
69
|
process.kill(pid, 0);
|
|
40
|
-
return true;
|
|
41
70
|
} catch (e) {
|
|
42
|
-
|
|
71
|
+
if (e.code === "ESRCH") return false;
|
|
43
72
|
}
|
|
73
|
+
// On POSIX, kill(pid, 0) also succeeds for a zombie. A zombie cannot run or
|
|
74
|
+
// answer requests, so keep the foreground supervisor from waiting forever.
|
|
75
|
+
return !isZombieProcess(pid);
|
|
44
76
|
};
|
|
45
77
|
export function readUpdateJob(home, id) {
|
|
46
78
|
updatePath(home, id);
|