luckytri 1.0.7 → 1.0.8

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.
Files changed (36) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/docs/en/CHANGELOG.md +10 -0
  4. package/docs/zh/CHANGELOG.md +10 -0
  5. package/package.json +1 -1
  6. package/public/guide.en.html +3 -3
  7. package/public/guide.html +3 -3
  8. package/server/core/conversation-cues.js +29 -9
  9. package/server/core/conversation-grounding.js +130 -4
  10. package/server/core/dialogue-context.js +194 -2
  11. package/server/core/dialogue-contract.js +35 -0
  12. package/server/core/initiative-context.js +4 -2
  13. package/server/core/model-manager.js +5 -8
  14. package/server/core/persona-manager.js +44 -9
  15. package/server/core/reader-check.js +62 -0
  16. package/server/core/reply-delivery.js +68 -3
  17. package/server/core/response-generator.js +43 -9
  18. package/server/core/response-validator.js +24 -6
  19. package/server/core/turn.js +43 -10
  20. package/server/i18n/errors.js +2 -0
  21. package/server/i18n/labels.js +6 -5
  22. package/server/mind/expression-grounding.js +21 -0
  23. package/server/mind/own-voice.js +35 -0
  24. package/server/mind/reading.js +15 -3
  25. package/server/mind/time/availability.js +23 -1
  26. package/server/mind/time/delivery-links.js +130 -130
  27. package/server/mind/time/executor.js +4 -2
  28. package/server/mind/time/games.js +13 -8
  29. package/server/mind/time/index.js +6 -2
  30. package/server/mind/time/kinds.js +29 -2
  31. package/server/mind/time/material-relevance.js +19 -0
  32. package/server/mind/time/reading-input.js +31 -0
  33. package/server/mind/time/sharing.js +10 -0
  34. package/server/mind/time/tasks.js +41 -3
  35. package/server/mind/time/works.js +104 -15
  36. package/server/mind/view.js +20 -7
@@ -114,11 +114,38 @@ export function pluginActivities() {
114
114
  return [...registry.values()].filter((def) => def.plugin);
115
115
  }
116
116
 
117
- // Same order as before: writing wins over a game, a game over reading.
117
+ // Identify her action before matching topic nouns. "She writes a novel and
118
+ // I promised to read it" is reading; the word "novel" is not a writing plan.
118
119
  export function classifyActivity(words) {
119
120
  const source = String(words || "");
121
+ const own =
122
+ source.match(/我(?:会|要|想|打算|准备|答应|先|来|再)([^]*)/) ||
123
+ source.match(/我(?=读|阅读|看|写|玩|游玩)([^]*)/);
124
+ const action = own?.[1] || source;
125
+ if (/^(?:让|请|希望)(?:你|他|她|对方)/.test(action)) return "unknown";
126
+ const verbs = [
127
+ ["write", /写|续写|改写|创作/],
128
+ [
129
+ "read",
130
+ /阅读|读(?:书|稿|正文|完|了|过|一|这|那|你|她|他|它|《)|看.{0,12}(?:书|稿|正文|小说|文章|资料)/,
131
+ ],
132
+ ["game", /游玩|盲开|玩(?:一|个|下|这|那|《)|通关|打完.{0,10}章/],
133
+ ["think", /思考|想一想|想想|整理思路/],
134
+ ]
135
+ .map(([kind, pattern]) => ({ kind, at: action.search(pattern) }))
136
+ .filter((item) => item.at >= 0)
137
+ .sort((a, b) => a.at - b.at);
138
+ if (verbs.length) {
139
+ if (
140
+ verbs[0].kind === "read" &&
141
+ registry.get("game").pattern.test(action) &&
142
+ !/稿|正文|小说|故事|书|文章|诗/.test(action)
143
+ )
144
+ return "game";
145
+ return verbs[0].kind;
146
+ }
120
147
  for (const kind of ["write", "game", "read", "think"])
121
- if (registry.get(kind).pattern.test(source)) return kind;
148
+ if (registry.get(kind).pattern.test(action)) return kind;
122
149
  for (const def of registry.values())
123
150
  if (def.plugin && def.enabled && def.pattern?.test(source)) return def.kind;
124
151
  return "unknown";
@@ -0,0 +1,19 @@
1
+ const normalized = (value) =>
2
+ String(value || "")
3
+ .normalize("NFKC")
4
+ .toLowerCase()
5
+ .replace(/[\p{P}\p{Z}\s]/gu, "");
6
+
7
+ // A result rank or internal segment number is never evidence that a web page
8
+ // belongs to this game. Require its actual title in the source, not the query.
9
+ export function belongsToGame(source, topic) {
10
+ const name = normalized(topic);
11
+ if (name.length < 2) return false;
12
+ const title = normalized(source.title);
13
+ const content = normalized(source.content);
14
+ let url = "";
15
+ try {
16
+ url = normalized(decodeURIComponent(source.url || ""));
17
+ } catch {}
18
+ return title.includes(name) || content.includes(name) || url.includes(name);
19
+ }
@@ -0,0 +1,31 @@
1
+ export function awaitsTextInput(words) {
2
+ const value = String(words || "");
3
+ if (
4
+ /(?:已经|刚才|刚刚)(?:收到|拿到)|正文(?:已|已经)?(?:到手|收到了)/.test(
5
+ value,
6
+ )
7
+ )
8
+ return false;
9
+ return /等(?:你|他|她|对方).{0,35}(?:发来|发过来|递来|递过来|提供)|等.{0,25}(?:稿子|稿件|正文|全文|资料|题目|要求).{0,18}(?:发|递|到|来|齐|提供)/.test(
10
+ value,
11
+ );
12
+ }
13
+
14
+ export function expectedTextTitle(task) {
15
+ return (
16
+ task.checkpoint?.expectedTextTitle ||
17
+ String(task.title || "").match(/[《「]([^》」]+)[》」]/)?.[1] ||
18
+ ""
19
+ );
20
+ }
21
+
22
+ export function nextReadingChunk(mind, task, now) {
23
+ const title = expectedTextTitle(task);
24
+ const pending = task.checkpoint?.pendingStep?.chunk;
25
+ if (pending) return !title || pending.title?.includes(title) ? pending : null;
26
+ if (task.checkpoint?.awaitedText && !title) return null;
27
+ return mind.reading.next(now, {
28
+ title,
29
+ after: task.checkpoint?.awaitedAfter || 0,
30
+ });
31
+ }
@@ -31,6 +31,8 @@ export class Sharing {
31
31
  const work = this.time.works.get(workId);
32
32
  if (!work || work.state !== "complete")
33
33
  throw Error("只有真实完成稿可以交付");
34
+ if (!this.time.works.materialSupported(work))
35
+ throw Error("这篇记录引用了与作品无关的资料,不能作为游戏阅读进度分享");
34
36
  if (
35
37
  leaks([work.content], this.time.mind.meetings.privateSayings(session))
36
38
  .length
@@ -154,6 +156,14 @@ export class Sharing {
154
156
  if (!current || current.state !== "pending") return null;
155
157
  share = current;
156
158
  const work = this.time.works.get(share.work_id, share.version);
159
+ if (work && !this.time.works.materialSupported(work)) {
160
+ this.db
161
+ .prepare(
162
+ "UPDATE mind_time_shares SET state='declined',reason=? WHERE id=?",
163
+ )
164
+ .run("引用资料与作品无关,保留记录但停止分享", share.id);
165
+ return { status: "declined", reason: "引用资料与作品无关" };
166
+ }
157
167
  if (
158
168
  !work ||
159
169
  !life.chat.enabled(share.session_id, { simulated: false }) ||
@@ -8,6 +8,7 @@ import { deadlineAt, taskSchedule, spokenDate } from "./schedule.js";
8
8
  import { DeliveryLinks } from "./delivery-links.js";
9
9
  import { activityPresentation } from "./presentation.js";
10
10
  import { activityLabel, classifyActivity, isExecutable } from "./kinds.js";
11
+ import { awaitsTextInput, nextReadingChunk } from "./reading-input.js";
11
12
 
12
13
  export const TASK_STATES = [
13
14
  "todo",
@@ -105,10 +106,13 @@ export class Tasks {
105
106
  const privateRoots = this.time.mind.meetings.privateRoots(refs, now);
106
107
  session = privateRoots[0] || session;
107
108
  if (privateRoots.length && discretion !== "secret") discretion = "private";
109
+ const inferred = classifyActivity(title);
108
110
  const type =
109
- activity && (ACTIVITY_LABELS[activity] || isExecutable(activity))
110
- ? activity
111
- : classifyActivity(title);
111
+ awaitsTextInput(title) && inferred === "read"
112
+ ? "read"
113
+ : activity && (ACTIVITY_LABELS[activity] || isExecutable(activity))
114
+ ? activity
115
+ : inferred;
112
116
  subject =
113
117
  intentRecipient(this.db, { session, subject, title, sources: refs }) ||
114
118
  null;
@@ -207,6 +211,24 @@ export class Tasks {
207
211
  { sources: refs },
208
212
  now,
209
213
  );
214
+ if (type === "read" && awaitsTextInput(title)) {
215
+ this.db
216
+ .prepare("UPDATE mind_time_tasks SET checkpoint=? WHERE id=?")
217
+ .run(
218
+ JSON.stringify({
219
+ awaitedText: true,
220
+ expectedTextTitle: intent.topic,
221
+ awaitedAfter: now,
222
+ }),
223
+ id,
224
+ );
225
+ this.wait(
226
+ this.get(id),
227
+ "等待约定的正文或资料",
228
+ Number.MAX_SAFE_INTEGER,
229
+ now,
230
+ );
231
+ }
210
232
  return { id };
211
233
  }
212
234
  sync(now = this.time.now()) {
@@ -326,6 +348,22 @@ export class Tasks {
326
348
  return ids;
327
349
  }
328
350
  ready(now = this.time.now()) {
351
+ for (const row of this.db
352
+ .prepare(
353
+ "SELECT * FROM mind_time_tasks WHERE state='waiting' AND activity='read' AND json_extract(checkpoint,'$.awaitedText')=1 AND json_extract(checkpoint,'$.mergedInto') IS NULL",
354
+ )
355
+ .all()) {
356
+ const task = decode(row);
357
+ if (
358
+ this.time.allowed(task, now) &&
359
+ nextReadingChunk(this.time.mind, task, now)
360
+ )
361
+ this.control(
362
+ task.id,
363
+ { action: "resume", reason: "约定的正文已可阅读" },
364
+ now,
365
+ );
366
+ }
329
367
  return this.db
330
368
  .prepare(
331
369
  "SELECT * FROM mind_time_tasks WHERE state IN ('todo','scheduled','waiting','paused') AND ready_at<=? AND next_step<=? AND activity!='unknown' AND json_extract(checkpoint,'$.mergedInto') IS NULL ORDER BY priority DESC,created LIMIT 100",
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { evidence, hasCredential, parse, text } from "../util.js";
3
3
  import { activityPresentation, gameText } from "./presentation.js";
4
+ import { interestTerms } from "../attention.js";
5
+ import { belongsToGame } from "./material-relevance.js";
6
+ import { gameTopic } from "./intent.js";
4
7
 
5
8
  const decode = (row) =>
6
9
  row
@@ -145,7 +148,7 @@ export class Works {
145
148
  : row,
146
149
  );
147
150
  }
148
- get(id, version) {
151
+ get(id, version, { before = Number.MAX_SAFE_INTEGER } = {}) {
149
152
  const work = this.db
150
153
  .prepare("SELECT * FROM mind_time_works WHERE id=?")
151
154
  .get(id);
@@ -156,9 +159,9 @@ export class Works {
156
159
  const saved = v
157
160
  ? this.db
158
161
  .prepare(
159
- "SELECT reason FROM mind_time_events WHERE kind='draft' AND json_extract(data,'$.workId')=? AND json_extract(data,'$.version')=? ORDER BY id DESC LIMIT 1",
162
+ "SELECT reason FROM mind_time_events WHERE kind='draft' AND json_extract(data,'$.workId')=? AND json_extract(data,'$.version')=? AND created<=? ORDER BY id DESC LIMIT 1",
160
163
  )
161
- .get(id, v.version)
164
+ .get(id, v.version, before)
162
165
  : null;
163
166
  return v
164
167
  ? {
@@ -170,7 +173,9 @@ export class Works {
170
173
  ? saved.reason === "保存完成稿"
171
174
  ? "complete"
172
175
  : "draft"
173
- : work.state,
176
+ : work.updated > before
177
+ ? "draft"
178
+ : work.state,
174
179
  sources: parse(v.sources, []),
175
180
  provenance: parse(v.provenance, {}),
176
181
  versions: this.db
@@ -396,9 +401,19 @@ export class Works {
396
401
  }
397
402
  fragments({ session, now = this.time.now(), cue = [] } = {}) {
398
403
  const words = (Array.isArray(cue) ? cue : [cue])
399
- .map((v) => (typeof v === "string" ? v : JSON.stringify(v)))
404
+ .map((v) => (typeof v === "string" ? v : v?.text || ""))
400
405
  .join(" ");
401
- return this.list({ limit: 30 })
406
+ const terms = interestTerms([words]);
407
+ // Search the work catalogue, not just the two newest activities. Select
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
412
+ FROM mind_time_works w LEFT JOIN mind_time_projects p ON p.id=w.project_id
413
+ 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
+ WHERE w.created<=? ORDER BY v.created DESC`,
415
+ )
416
+ .all(now, now)
402
417
  .filter(
403
418
  (w) =>
404
419
  w.created <= now &&
@@ -410,29 +425,103 @@ export class Works {
410
425
  )
411
426
  .map((w) => ({
412
427
  ...w,
413
- score: [...new Set(words.match(/[\p{L}\p{N}]{2,}/gu) || [])].filter(
414
- (term) =>
415
- (w.title + " " + w.project_title + " " + w.summary).includes(term),
416
- ).length,
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
+ ),
417
434
  }))
418
- .sort((a, b) => b.score - a.score || b.updated - a.updated)
419
- .slice(0, 2)
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)
420
443
  .map((w) => ({
421
444
  id: w.id,
422
445
  title: w.title,
423
- state: w.state,
446
+ state: this.get(w.id, w.version, { before: now }).state,
424
447
  kind: w.kind,
425
448
  version: w.version,
426
449
  ordinal: w.ordinal,
427
450
  summary: text(w.summary, 180),
428
- fragment: text(this.get(w.id).content, 240),
451
+ fragment: text(this.get(w.id, w.version).content, 300),
429
452
  ...(w.kind === "game"
430
453
  ? {
431
454
  experienceMode: "reference",
432
- provenance: this.get(w.id).provenance,
455
+ provenance: this.get(w.id, w.version).provenance,
433
456
  }
434
457
  : {}),
435
458
  characters: w.characters,
436
459
  }));
437
460
  }
461
+
462
+ activityEvidence({ session, now = this.time.now(), cue = [] } = {}) {
463
+ const terms = interestTerms(
464
+ (Array.isArray(cue) ? cue : [cue]).map((v) =>
465
+ typeof v === "string"
466
+ ? v
467
+ : v?.role === "assistant"
468
+ ? ""
469
+ : v?.text || "",
470
+ ),
471
+ );
472
+ if (!terms.size) return [];
473
+ // A private conversation can motivate her own reading. Its contents and
474
+ // promises stay private; absence from the public fragments must not erase
475
+ // the fact that the explicitly named reading activity took place.
476
+ return this.db
477
+ .prepare(
478
+ `SELECT w.id,w.title,w.discretion,w.session_id,p.kind,p.title project_title,p.bible,v.created,v.provenance,v.sources
479
+ FROM mind_time_works w JOIN mind_time_projects p ON p.id=w.project_id
480
+ 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<=?)
481
+ WHERE w.created<=? AND p.kind='game' AND w.discretion!='secret' ORDER BY v.created DESC`,
482
+ )
483
+ .all(now, now)
484
+ .filter((w) => {
485
+ const shared = [...interestTerms([w.title, w.project_title])].filter(
486
+ (term) => terms.has(term),
487
+ );
488
+ return (
489
+ (shared.length >= 2 ||
490
+ shared.some((term) => /^[a-z][a-z0-9]{2,}$/.test(term))) &&
491
+ this.time.allowed({ ...w, sources: parse(w.sources, []) }, now) &&
492
+ this.materialSupported(w)
493
+ );
494
+ })
495
+ .slice(0, 3)
496
+ .map((w) => ({
497
+ title: w.title,
498
+ activity: "阅读游戏资料并写笔记",
499
+ experienceMode: "reference",
500
+ ...(w.session_id !== session && w.discretion !== "open"
501
+ ? { privateOrigin: true }
502
+ : {}),
503
+ instruction:
504
+ "仅用于核对自己确有这项阅读和笔记,不能据此否认从未发生;这里不提供私下正文、发起人、约定或对话。只能自然回应本轮对方已经提到的阅读本身,不披露私下内容。",
505
+ }));
506
+ }
507
+
508
+ materialSupported(work) {
509
+ const project = work.kind ? null : this.project(work.project_id);
510
+ if ((work.kind || project?.kind) !== "game") return true;
511
+ const ids = parse(work.provenance, {}).sourceIds;
512
+ const topic =
513
+ parse(work.bible, {}).topic ||
514
+ project?.bible?.topic ||
515
+ gameTopic(work.project_title || project?.title);
516
+ // Missing old provenance is unknown, not evidence that reading never took
517
+ // place. Known references all belonging to another subject are different.
518
+ if (!Array.isArray(ids) || !ids.length || !topic) return true;
519
+ const source = this.db.prepare(
520
+ "SELECT title,url,content FROM mind_time_sources WHERE id=?",
521
+ );
522
+ return ids.some((id) => {
523
+ const row = source.get(id);
524
+ return row && belongsToGame(row, topic);
525
+ });
526
+ }
438
527
  }
@@ -6,7 +6,10 @@ import { describeFace } from "./faces.js";
6
6
  import { SELF_KINDS } from "./self.js";
7
7
  import { DAY, text } from "./util.js";
8
8
  import { relationshipHistory } from "./relationship-context.js";
9
- import { fixedReplyHabit } from "../core/conversation-grounding.js";
9
+ import {
10
+ fixedReplyHabit,
11
+ conversationalMemory,
12
+ } from "../core/conversation-grounding.js";
10
13
 
11
14
  const FORGOTTEN = "(很久没想起了)";
12
15
 
@@ -172,9 +175,14 @@ export function innerView(
172
175
  ? {
173
176
  here: describeFace({
174
177
  ...face,
175
- notes: face.notes.filter((note) =>
176
- spoken(`${note.content} ${note.why}`),
177
- ),
178
+ notes: face.notes
179
+ .map((note) => ({
180
+ ...note,
181
+ content: conversationalMemory(note.content),
182
+ }))
183
+ .filter(
184
+ (note) => note.content && spoken(`${note.content} ${note.why}`),
185
+ ),
178
186
  role: spoken(face.role),
179
187
  tone: spoken(face.tone),
180
188
  aspiration: spoken(face.aspiration),
@@ -240,7 +248,11 @@ export function innerView(
240
248
  const thoughts = relevantContext(
241
249
  mind.thoughts
242
250
  .open({ now, limit: 400, session })
243
- .filter((t) => mind.meetings.sayable(t.content, session)),
251
+ .filter(
252
+ (t) =>
253
+ !fixedReplyHabit(t.content) &&
254
+ mind.meetings.sayable(t.content, session),
255
+ ),
244
256
  { cues, limit: 3, requireOverlap: cue.length > 0 },
245
257
  );
246
258
  const heard = feedback(mind, session, now);
@@ -257,6 +269,7 @@ export function innerView(
257
269
  });
258
270
  const wills = mind.meetings.traces({ session, before: now, limit: 2 });
259
271
  const will = wills
272
+ .filter((row) => !fixedReplyHabit(row.text))
260
273
  .map((row) =>
261
274
  row.living ? row.text : `${text(row.content, 28)}:${row.text}`,
262
275
  )
@@ -272,8 +285,8 @@ export function innerView(
272
285
  affect.cause && mind.meetings.sayable(affect.cause, session)
273
286
  ? text(affect.cause, 40)
274
287
  : "";
275
- const withLines = withWhom.filter((line) =>
276
- mind.meetings.sayable(line, session),
288
+ const withLines = withWhom.filter(
289
+ (line) => !fixedReplyHabit(line) && mind.meetings.sayable(line, session),
277
290
  );
278
291
  const expectingLines = expecting.filter((line) =>
279
292
  mind.meetings.sayable(line, session),