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.
@@ -406,57 +406,60 @@ export class Works {
406
406
  const terms = interestTerms([words]);
407
407
  // Search the work catalogue, not just the two newest activities. Select
408
408
  // the version that existed at the requested time, including in replays.
409
- return this.db
410
- .prepare(
411
- `SELECT w.*,COALESCE(p.kind,(SELECT kind FROM mind_creations WHERE id=w.legacy_creation)) kind,p.title project_title,p.bible,v.version,v.title,v.created version_created,v.summary,v.sources,v.provenance,length(v.content) characters
409
+ return (
410
+ this.db
411
+ .prepare(
412
+ `SELECT w.*,COALESCE(p.kind,(SELECT kind FROM mind_creations WHERE id=w.legacy_creation)) kind,p.title project_title,p.bible,v.version,v.title,v.created version_created,v.summary,v.sources,v.provenance,length(v.content) characters
412
413
  FROM mind_time_works w LEFT JOIN mind_time_projects p ON p.id=w.project_id
413
414
  JOIN mind_time_versions v ON v.work_id=w.id AND v.version=(SELECT max(h.version) FROM mind_time_versions h WHERE h.work_id=w.id AND h.created<=?)
414
415
  WHERE w.created<=? ORDER BY v.created DESC`,
415
- )
416
- .all(now, now)
417
- .filter(
418
- (w) =>
419
- w.created <= now &&
420
- this.time.visible(
421
- { ...w, sources: parse(w.sources, []) },
422
- session,
423
- now,
424
- ),
425
- )
426
- .map((w) => ({
427
- ...w,
428
- score: [...interestTerms([w.title, w.project_title, w.summary])]
429
- .filter((term) => terms.has(term))
430
- .reduce(
431
- (total, term) => total + (/^[a-z][a-z0-9]{2,}$/.test(term) ? 8 : 1),
432
- 0,
433
- ),
434
- }))
435
- .sort(
436
- (a, b) => b.score - a.score || b.version_created - a.version_created,
437
- )
438
- .filter((w, index, rows) =>
439
- rows[0]?.score >= 2 ? w.score >= 2 : index < 2,
440
- )
441
- .filter((w) => this.materialSupported(w))
442
- .slice(0, 3)
443
- .map((w) => ({
444
- id: w.id,
445
- title: w.title,
446
- state: this.get(w.id, w.version, { before: now }).state,
447
- kind: w.kind,
448
- version: w.version,
449
- ordinal: w.ordinal,
450
- summary: text(w.summary, 180),
451
- fragment: text(this.get(w.id, w.version).content, 300),
452
- ...(w.kind === "game"
453
- ? {
454
- experienceMode: "reference",
455
- provenance: this.get(w.id, w.version).provenance,
456
- }
457
- : {}),
458
- characters: w.characters,
459
- }));
416
+ )
417
+ .all(now, now)
418
+ .filter(
419
+ (w) =>
420
+ w.created <= now &&
421
+ this.time.visible(
422
+ { ...w, sources: parse(w.sources, []) },
423
+ session,
424
+ now,
425
+ ),
426
+ )
427
+ .map((w) => ({
428
+ ...w,
429
+ score: [...interestTerms([w.title, w.project_title, w.summary])]
430
+ .filter((term) => terms.has(term))
431
+ .reduce(
432
+ (total, term) =>
433
+ total + (/^[a-z][a-z0-9]{2,}$/.test(term) ? 8 : 1),
434
+ 0,
435
+ ),
436
+ }))
437
+ .sort(
438
+ (a, b) => b.score - a.score || b.version_created - a.version_created,
439
+ )
440
+ // No topic hit → empty. Do not fall back to "latest two" and pollute
441
+ // ordinary chatter with manuscript fragments.
442
+ .filter((w) => w.score >= 2)
443
+ .filter((w) => this.materialSupported(w))
444
+ .slice(0, 3)
445
+ .map((w) => ({
446
+ id: w.id,
447
+ title: w.title,
448
+ state: this.get(w.id, w.version, { before: now }).state,
449
+ kind: w.kind,
450
+ version: w.version,
451
+ ordinal: w.ordinal,
452
+ summary: text(w.summary, 180),
453
+ fragment: text(this.get(w.id, w.version).content, 300),
454
+ ...(w.kind === "game"
455
+ ? {
456
+ experienceMode: "reference",
457
+ provenance: this.get(w.id, w.version).provenance,
458
+ }
459
+ : {}),
460
+ characters: w.characters,
461
+ }))
462
+ );
460
463
  }
461
464
 
462
465
  activityEvidence({ session, now = this.time.now(), cue = [] } = {}) {
@@ -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
  }