luckytri 1.3.0 → 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.
@@ -2,6 +2,16 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 1.3.2 — 2026-10-14
6
+
7
+ - Stop filling replies with mechanical fallback phrases after repeated validation failures. Ordinary turns stay quiet; clear crisis turns retain a care response.
8
+ - 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.
9
+ - Keep @ mentions attached to their intended message bubble, including when an @-only empty bubble is removed.
10
+
11
+ ## 1.3.1 — 2026-10-14
12
+
13
+ - 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.
14
+
5
15
  ## 1.3.0 — 2026-10-14
6
16
 
7
17
  - 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.
@@ -2,6 +2,16 @@
2
2
 
3
3
  # 版本记录
4
4
 
5
+ ## 1.3.2 — 2026-10-14
6
+
7
+ - 回复两次仍未通过校验时,不再用机械短句填空;普通回合保持安静,明确的危机回合仍会给出关心回应。
8
+ - 减少公开身份介绍被误判为泄露私密记忆,也避免旧玩笑或总结否认已经公开的亲属关系;表达想念时不再带入生活愿望与进度。
9
+ - 修正多气泡消息中的 @ 分配,并覆盖清除空 @ 气泡后的提及错位。
10
+
11
+ ## 1.3.1 — 2026-10-14
12
+
13
+ - 修正 Linux 与 macOS 的前台实例更新后停止逻辑:将未回收的僵尸进程识别为已退出,避免监管进程永久等待;增加 POSIX 僵尸进程回归测试。
14
+
5
15
  ## 1.3.0 — 2026-10-14
6
16
 
7
17
  - 新增可选语音输出和语音识别,支持火山语音与 OpenAI 兼容接口。语音默认关闭;她逐句决定是否朗读或唱歌,收到的语音可转成文字,识别失败时继续按文本回退。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luckytri",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "让由技术诞生的她,在时间、记忆、相遇与选择中逐渐形成自己的方向",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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.0 · Getting Started</title>
6
+ <title>LuckyTri v1.3.2 · 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.0</small>
272
+ <b>LuckyTri · Handbook</b><small>TIME · MEMORY · CHOICE / v1.3.2</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.0 interface.
1079
+ the v1.3.2 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.0 · 使用教程</title>
6
+ <title>LuckyTri v1.3.2 · 使用教程</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.0</small>
272
+ <b>LuckyTri · 上手手册</b><small>TIME · MEMORY · CHOICE / v1.3.2</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.0 界面同步。</footer>
886
+ <footer>LuckyTri · 让经历和选择彼此连贯。教程与 v1.3.2 界面同步。</footer>
887
887
  </body>
888
888
  </html>
@@ -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,
@@ -247,33 +248,39 @@ export function dialogueContext(snapshot) {
247
248
  note: "这些是你最近说得太多的词,这次换成你自己平常的说法",
248
249
  },
249
250
  };
250
- if (inner.currentLife) {
251
- // Affection first: keep only that she is still herself, drop progress,
252
- // works and plans so a "想你了" is not answered with a reading report.
253
- if (affectionBatch(snapshot)) {
254
- // Title itself is the progress report ("接着读…资料"). Drop the whole
255
- // board; mood stays in inner.state. justLived's "顺口给句背景" also
256
- // pushes her to narrate what she was doing — drop it on affection.
257
- delete inner.currentLife;
258
- delete inner.justLived;
259
- } else {
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
- };
251
+ if (affectionBatch(snapshot)) {
252
+ // Affection first: keep only that she is still herself. Drop progress,
253
+ // justLived's "顺口给句背景", and livingFor wishes so a "想你了" is not
254
+ // answered with a reading report or a work-volume question.
255
+ delete inner.currentLife;
256
+ delete inner.justLived;
257
+ if (self) delete self.livingFor;
258
+ if (inner.selfKnown && typeof inner.selfKnown === "object") {
259
+ const { livingFor: _wish, ...rest } = inner.selfKnown;
260
+ inner.selfKnown = rest;
275
261
  }
262
+ } else if (inner.currentLife) {
263
+ const life = inner.currentLife;
264
+ const compact = (row) => ({
265
+ id: row.id,
266
+ title: row.title,
267
+ state: row.state,
268
+ ...(row.wait ? { wait: row.wait } : {}),
269
+ ...(row.plannedFor ? { plannedFor: row.plannedFor } : {}),
270
+ });
271
+ inner.currentLife = {
272
+ ...life,
273
+ pending: (life.pending || []).map(compact),
274
+ intentions: (life.intentions || []).map(compact),
275
+ worksCoverage:
276
+ "以下是按本轮话题选出的作品片段,不是全部历史;缺少一篇不能证明从未写过或读过。",
277
+ };
276
278
  }
279
+ if (Array.isArray(context.summaries))
280
+ context.summaries = softenRelationDenialSummaries(
281
+ context.summaries,
282
+ inner.relationships,
283
+ );
277
284
  return {
278
285
  ...context,
279
286
  self,
@@ -16,10 +16,14 @@ export async function deliver(
16
16
  // Platform id of an earlier message the first bubble answers, when the
17
17
  // conversation has moved past it; the channel may show it as a quote.
18
18
  quote = "",
19
- // People she chose to @, on her first message only, the way one types.
19
+ // Legacy: people to @ on the first bubble only.
20
20
  at = [],
21
+ // Preferred: per-bubble addressees. Bubble i only @s atByBubble[i].
22
+ atByBubble = null,
21
23
  } = {},
22
24
  ) {
25
+ const peopleFor = (i) =>
26
+ Array.isArray(atByBubble) ? atByBubble[i] || [] : i === 0 ? at : [];
23
27
  const ids = bubbles.map((text, i) => {
24
28
  const id = randomUUID();
25
29
  repo.db
@@ -47,16 +51,15 @@ export async function deliver(
47
51
  .prepare("UPDATE core_outbox SET status='sending',text=? WHERE id=?")
48
52
  .run(outgoing, ids[i]);
49
53
  try {
50
- const first = i === 0 && (quote || at.length);
54
+ const people = peopleFor(i);
55
+ const opts = {
56
+ ...(i === 0 && quote ? { replyTo: quote } : {}),
57
+ ...(people.length ? { at: people.map((p) => p.userId) } : {}),
58
+ };
51
59
  const result = await send(
52
60
  message,
53
61
  outgoing,
54
- first
55
- ? {
56
- ...(quote ? { replyTo: quote } : {}),
57
- ...(at.length ? { at: at.map((p) => p.userId) } : {}),
58
- }
59
- : undefined,
62
+ Object.keys(opts).length ? opts : undefined,
60
63
  );
61
64
  repo.db
62
65
  .prepare(
@@ -70,8 +73,8 @@ export async function deliver(
70
73
  traceId: trace.id,
71
74
  replyTargetIds: trace.decision?.targetMessageIds || [],
72
75
  },
73
- i === 0 && at.length
74
- ? `${at.map((p) => `@${p.name}`).join(" ")} ${outgoing}`
76
+ people.length
77
+ ? `${people.map((p) => `@${p.name}`).join(" ")} ${outgoing}`
75
78
  : outgoing,
76
79
  result?.message_id,
77
80
  now(),
@@ -25,7 +25,7 @@ import {
25
25
  validateResponse,
26
26
  } from "./response-validator.js";
27
27
  import { deliver, sleep } from "./message-scheduler.js";
28
- import { leaks } from "../mind/guard.js";
28
+ import { leaks, publicIdentityPhrases } from "../mind/guard.js";
29
29
  import { isGroupSession } from "../channels/session-key.js";
30
30
  import { interestTerms } from "../mind/attention.js";
31
31
  import {
@@ -191,9 +191,8 @@ export class ReplyDelivery {
191
191
  )
192
192
  ? ["在。"]
193
193
  : []),
194
- // Holding a private thing is not a cue to announce the boundary; the
195
- // ordinary "还没理清楚" line is enough, or silence.
196
- "这句我还没理清楚,先不乱说。",
194
+ // Format / empty-bubble helpers only. Validation failure never uses these
195
+ // to announce a privacy boundary — that path silences (see below).
197
196
  "我还没想明白。",
198
197
  ];
199
198
  const fallbackText = turn.crisis?.clear
@@ -259,7 +258,14 @@ export class ReplyDelivery {
259
258
  );
260
259
  if (leaks(response.bubbles, secrets).length)
261
260
  issues.push("这句话说出了别人要求保密的事,不能在这里说");
262
- if (leaks(response.bubbles, privateFacts, alreadySaid).length)
261
+ // Public identity (name / being code·model·AI) is not a private leak when
262
+ // she answers who she is; fold those phrases into alreadySaid.
263
+ if (
264
+ leaks(response.bubbles, privateFacts, [
265
+ ...alreadySaid,
266
+ ...publicIdentityPhrases(nature),
267
+ ]).length
268
+ )
263
269
  issues.push("这句话把私下知道的事说出来了,不能在这里说");
264
270
  if (bookkeeping(response.bubbles, snapshot))
265
271
  issues.push(
@@ -813,19 +819,32 @@ export class ReplyDelivery {
813
819
  return finish("error", "想说的话还没整理好,愿望保留,稍后重新决定");
814
820
  }
815
821
  trace.validation = issues;
816
- // Nobody called her here: a filler word says nothing to the room, and
817
- // often repeats the last one. Not saying it is the plain answer.
818
- if (!c.direct && !c.privateChat && !turn.crisis?.clear && !c.preview) {
822
+ // By speak time she should already know what she may say. Failed checks
823
+ // mean the words are not ready: regenerate already ran above; do not
824
+ // fill with a secrecy / "safety" short phrase — silence instead.
825
+ // Crisis keeps a care line when that is the turn.
826
+ if (turn.crisis?.clear) {
827
+ const care = {
828
+ bubbles: ["你现在还好吗?身边有人能陪着你吗?"],
829
+ reason: "危机关心",
830
+ };
831
+ if (!check(care).length) {
832
+ trace.steps.push("校验未过,危机回合改用关心短句");
833
+ response = care;
834
+ trace.response = response;
835
+ } else {
836
+ trace.steps.push("校验未过且关心短句也不可用,不说了");
837
+ return finish("silent", "话没整理好,就不说了");
838
+ }
839
+ } else {
819
840
  trace.steps.push(
820
- "没有人在叫她,回复两次仍未通过校验,不用空话顶替,不说了",
841
+ c.direct || c.privateChat
842
+ ? "有人在叫她,回复仍未通过校验,不拿短句顶替,不说了"
843
+ : "没有人在叫她,回复两次仍未通过校验,不用空话顶替,不说了",
821
844
  );
822
- return finish("silent", "没被叫到,话没整理好,就不说了");
845
+ trace.response = { bubbles: [], reason: "话没整理好" };
846
+ return finish("silent", "话没整理好,就不说了");
823
847
  }
824
- // Checks protect the words; when someone did call her, they must not
825
- // erase the decision to answer.
826
- trace.steps.push("回复两次生成仍未通过校验,使用本地安全短句");
827
- response = { bubbles: [fallbackText], reason: "本地安全短句" };
828
- trace.response = response;
829
848
  }
830
849
  if (c.replay) return finish("replayed", "隔离回放完成,未发送或写入记忆");
831
850
  if (c.preview) return finish("previewed", turn.reason);
@@ -874,6 +893,7 @@ export class ReplyDelivery {
874
893
  turn.actions?.find((a) => a.type === "reply")?.platformId ||
875
894
  buriedTarget(batch, turn.targetMessageIds, session),
876
895
  at: typed.at,
896
+ atByBubble: typed.atByBubble,
877
897
  prepareBubble: (line) =>
878
898
  this.owner.mind.time.reconcile(
879
899
  { bubbles: [line] },
@@ -72,11 +72,16 @@ export const DECISIONS = {
72
72
  "What she wants to say is not sorted out yet; the wish is kept and she will decide again later",
73
73
  "没有人在叫她,回复两次仍未通过校验,不用空话顶替,不说了":
74
74
  "No one was calling her and the reply failed validation twice; no filler, she stays quiet",
75
- "没被叫到,话没整理好,就不说了":
76
- "Not called on and the words were not ready; she stays quiet",
77
- "回复两次生成仍未通过校验,使用本地安全短句":
78
- "The reply failed validation twice; a safe local short phrase is used",
79
- 本地安全短句: "Safe local short phrase",
75
+ "有人在叫她,回复仍未通过校验,不拿短句顶替,不说了":
76
+ "Someone was calling her and the reply still failed validation; no filler phrase, she stays quiet",
77
+ "话没整理好,就不说了":
78
+ "The words were not ready; she stays quiet",
79
+ "校验未过,危机回合改用关心短句":
80
+ "Validation failed; on a crisis turn a care line is used instead",
81
+ "校验未过且关心短句也不可用,不说了":
82
+ "Validation failed and the care line is also unusable; she stays quiet",
83
+ 危机关心: "Crisis care",
84
+ 话没整理好: "Words not ready",
80
85
  "隔离回放完成,未发送或写入记忆":
81
86
  "Isolated replay finished; nothing was sent or written to memory",
82
87
 
@@ -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
- // Secrets never enter another conversation's context, but a reply is still
40
- // checked against them before it leaves.
41
- export function leaks(bubbles, secrets, alreadySaid = []) {
42
- const said = normalized((bubbles || []).join(""));
43
- if (said.length < 4) return [];
44
- const pairs = (s) =>
45
- new Set(Array.from({ length: s.length - 1 }, (_, i) => s.slice(i, i + 2)));
46
- const spoken = pairs(said);
47
- const known = alreadySaid.map(normalized);
48
- const alreadyKnown = (phrase) => known.some((line) => line.includes(phrase));
49
- const found = [];
50
- for (const secret of secrets || []) {
51
- const key = normalized(secret.content);
52
- if (key.length < 4) continue;
53
- const grams = pairs(key);
54
- let hit =
55
- (said.includes(key) && !alreadyKnown(key)) ||
56
- [...grams].filter((g) => spoken.has(g) && !alreadyKnown(g)).length /
57
- grams.size >=
58
- 0.6;
59
- for (let i = 0; !hit && i + 6 <= key.length; i += 2)
60
- if (
61
- said.includes(key.slice(i, i + 6)) &&
62
- !alreadyKnown(key.slice(i, i + 6))
63
- )
64
- hit = true;
65
- if (hit) found.push(secret);
66
- }
67
- return found;
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
+ }
@@ -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
- // Narrow, literal factual denials only. Disagreement with a label, old words,
37
- // fictional dialogue and feelings are left to the contextual reviewer.
38
- export function relationshipClaimIssues(bubbles, knowledge) {
39
- const issues = [];
40
- for (const r of knowledge?.known || []) {
41
- const role = String(r.peerRole).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
- const names = [r.name, ...(r.knownAs || [])].filter(Boolean);
43
- for (const line of bubbles || []) {
44
- for (const clause of String(line).split(/[,,。!?!?;;\n]/)) {
45
- if (
46
- /以前|之前|当时|过去|曾经|从前|一开始|原来|小说|台词|[“「"『]/.test(
47
- clause,
48
- )
49
- )
50
- continue;
51
- const namedDenial = names.some((name) => {
52
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
- return new RegExp(
54
- `(?:^|[\\s::])${escaped}(?:也|本来|根本)?(?:不是|并非)我(?:的)?${role}(?:$|[\\s,。])|我(?:并)?不认识\\s*${escaped}(?:$|[\\s,。])`,
55
- "i",
56
- ).test(clause);
57
- });
58
- const absentRole = new RegExp(
59
- `我(?:本来|本来也|现在|根本|一直|从来)?(?:就|也)?(?:没有|没)${role}(?:$|[\\s,。])`,
60
- ).test(clause);
61
- if (namedDenial || absentRole) {
62
- issues.push(
63
- `当前已知 ${r.name || r.subjectId} 与自己的关系是 ${r.peerRole},不能把旧印象当成关系不存在;可保留自己的感受和称呼选择,按当前记录核对再说`,
64
- );
65
- break;
66
- }
67
- }
68
- }
69
- }
70
- return [...new Set(issues)];
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
+ }
@@ -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
- return e.code !== "ESRCH";
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);
@@ -103,24 +103,53 @@ export function expressionInput(snapshot) {
103
103
  }
104
104
 
105
105
  // "@小红" typed as text is not an @ on QQ. A name she wrote that way for
106
- // someone offered becomes a real @, and the typed copy is removed so the
107
- // message does not show the name twice.
106
+ // someone offered becomes a real @ on THAT bubble only, and the typed copy
107
+ // is removed so the message does not show the name twice. Two bubbles for
108
+ // two people must not dump both @s onto the first bubble.
108
109
  export function typedAt(bubbles, at, snapshot) {
109
110
  const offered = snapshot.canAt || [];
110
- const chosen = new Map(at.map((p) => [p.userId, p]));
111
- const out = bubbles.map((line, i) => {
112
- let text = line;
111
+ const fromTurn = new Map((at || []).map((p) => [String(p.userId), p]));
112
+ const out = [];
113
+ const atByBubble = [];
114
+ const typedIds = new Set();
115
+ for (const line of bubbles || []) {
116
+ let text = String(line || "");
117
+ const onThis = new Map();
113
118
  for (const p of offered) {
119
+ if (!p.name) continue;
114
120
  const mark = `@${p.name}`;
115
- if (!p.name || !text.includes(mark)) continue;
116
- if (i === 0 && chosen.size < 2) chosen.set(p.userId, p);
117
- if (chosen.has(p.userId))
118
- text = text
119
- .split(mark)
120
- .join("")
121
- .replace(/^[\s,,]+/, "");
121
+ if (!text.includes(mark)) continue;
122
+ const id = String(p.userId);
123
+ typedIds.add(id);
124
+ onThis.set(id, fromTurn.get(id) || p);
125
+ text = text
126
+ .split(mark)
127
+ .join("")
128
+ .replace(/^[\s,,]+/, "");
122
129
  }
123
- return text.trim();
124
- });
125
- return { bubbles: out.filter(Boolean), at: [...chosen.values()] };
130
+ text = text.trim();
131
+ if (!text) continue;
132
+ out.push(text);
133
+ atByBubble.push([...onThis.values()].slice(0, 2));
134
+ }
135
+ const placed = new Set(atByBubble.flat().map((p) => String(p.userId)));
136
+ const unplaced = [...fromTurn.values()].filter(
137
+ (p) => !placed.has(String(p.userId)) && !typedIds.has(String(p.userId)),
138
+ );
139
+ if (unplaced.length) {
140
+ if (out.length === 1) {
141
+ atByBubble[0] = [...(atByBubble[0] || []), ...unplaced].slice(0, 2);
142
+ } else if (out.length > 1 && atByBubble.every((list) => !list.length)) {
143
+ // turn.at with no typed @ marks: pair person i with bubble i
144
+ unplaced.forEach((p, i) => {
145
+ if (i < atByBubble.length) atByBubble[i] = [p];
146
+ });
147
+ }
148
+ }
149
+ return {
150
+ bubbles: out,
151
+ atByBubble,
152
+ // First bubble only — never the union of every addressee.
153
+ at: atByBubble[0] || [],
154
+ };
126
155
  }