luckytri 1.0.7 → 1.0.9

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 (49) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/docs/en/CHANGELOG.md +45 -24
  4. package/docs/en/guide.md +3 -1
  5. package/docs/zh/CHANGELOG.md +45 -24
  6. package/docs/zh/guide.md +3 -1
  7. package/package.json +1 -1
  8. package/public/guide.en.html +13 -5
  9. package/public/guide.html +12 -5
  10. package/public/tutorial.en.md +3 -1
  11. package/public/tutorial.md +3 -1
  12. package/server/core/context-builder.js +22 -3
  13. package/server/core/contribution-check.js +50 -0
  14. package/server/core/conversation-cues.js +29 -17
  15. package/server/core/conversation-grounding.js +377 -8
  16. package/server/core/dialogue-context.js +197 -2
  17. package/server/core/dialogue-contract.js +59 -0
  18. package/server/core/initiative-context.js +40 -18
  19. package/server/core/model-json.js +36 -3
  20. package/server/core/model-manager.js +7 -10
  21. package/server/core/model-presets.js +169 -24
  22. package/server/core/persona-manager.js +52 -12
  23. package/server/core/reader-check.js +166 -0
  24. package/server/core/reply-delivery.js +216 -10
  25. package/server/core/repository.js +33 -0
  26. package/server/core/response-generator.js +53 -10
  27. package/server/core/response-validator.js +42 -9
  28. package/server/core/turn-processor.js +26 -4
  29. package/server/core/turn.js +298 -51
  30. package/server/i18n/decisions.js +16 -0
  31. package/server/i18n/errors.js +4 -0
  32. package/server/i18n/labels.js +6 -5
  33. package/server/i18n/models.js +11 -2
  34. package/server/mind/expression-grounding.js +24 -0
  35. package/server/mind/own-voice.js +35 -0
  36. package/server/mind/reading.js +15 -3
  37. package/server/mind/time/activity-recall.js +65 -0
  38. package/server/mind/time/availability.js +23 -1
  39. package/server/mind/time/delivery-links.js +130 -130
  40. package/server/mind/time/executor.js +4 -2
  41. package/server/mind/time/games.js +13 -8
  42. package/server/mind/time/index.js +11 -3
  43. package/server/mind/time/kinds.js +29 -2
  44. package/server/mind/time/material-relevance.js +19 -0
  45. package/server/mind/time/reading-input.js +31 -0
  46. package/server/mind/time/sharing.js +10 -0
  47. package/server/mind/time/tasks.js +39 -3
  48. package/server/mind/time/works.js +105 -15
  49. package/server/mind/view.js +29 -9
@@ -16,7 +16,7 @@ export class Reading {
16
16
  shared() {
17
17
  return this.db
18
18
  .prepare(
19
- `SELECT c.id, c.document_id, c.ordinal, c.heading, c.text, d.title,
19
+ `SELECT c.id, c.document_id, c.ordinal, c.heading, c.text, d.title,d.created documentCreated,
20
20
  (SELECT COUNT(*) FROM core_chunks x WHERE x.document_id=c.document_id) total
21
21
  FROM core_chunks c
22
22
  JOIN core_documents d ON d.id=c.document_id AND d.status='ready'
@@ -28,8 +28,20 @@ export class Reading {
28
28
  }
29
29
  // The next part of something she already started, or the unread passage
30
30
  // closest to what she is living for, then what she likes and wonders about.
31
- next(now = Date.now()) {
32
- const unread = this.shared();
31
+ next(now = Date.now(), { title = "", after = 0 } = {}) {
32
+ const clean = (value) =>
33
+ String(value || "")
34
+ .toLowerCase()
35
+ .replace(/[\s\p{P}]/gu, "");
36
+ const wanted = clean(title);
37
+ const unread = this.shared().filter(
38
+ (chunk) =>
39
+ (!wanted ||
40
+ clean(chunk.title) === wanted ||
41
+ (wanted.length >= 2 && clean(chunk.title).includes(wanted))) &&
42
+ (!after ||
43
+ (chunk.documentCreated >= after && chunk.documentCreated <= now)),
44
+ );
33
45
  if (!unread.length) return null;
34
46
  const last = this.db
35
47
  .prepare(
@@ -0,0 +1,65 @@
1
+ const OWN_REFERENCE =
2
+ /你.{0,16}(?:不是说|明明说|说过|提过|答应过)|你跟.{0,16}(?:说|提过|答应过)|(?:上次|之前|刚才|昨天).{0,18}(?:你|我).{0,10}(?:说|提|答应)/;
3
+
4
+ // A same-person follow-up may point to what she just said without repeating
5
+ // a work's title. Use only replies actually addressed to this speaker as extra
6
+ // search terms; the private reply itself never enters the memory result.
7
+ export function activityRecallCue(
8
+ batch = [],
9
+ messages = [],
10
+ { excludedSpeakers = new Set() } = {},
11
+ ) {
12
+ const current = batch.filter(
13
+ (message) =>
14
+ message.role === "user" &&
15
+ !excludedSpeakers.has(String(message.speaker ?? message.userId ?? "")) &&
16
+ OWN_REFERENCE.test(message.text || ""),
17
+ );
18
+ if (!current.length) return batch;
19
+
20
+ const speakers = new Set(
21
+ current
22
+ .map((message) => String(message.speaker ?? message.userId ?? ""))
23
+ .filter(Boolean),
24
+ );
25
+ if (!speakers.size) return batch;
26
+ const byId = new Map(
27
+ messages.map((message) => [Number(message.seq ?? message.id), message]),
28
+ );
29
+ const cutoff = Math.min(
30
+ ...batch
31
+ .map((message) => Number(message.id ?? message.seq))
32
+ .filter(Number.isFinite),
33
+ );
34
+ const now = Math.max(
35
+ ...batch.map((message) => Number(message.time)).filter(Number.isFinite),
36
+ );
37
+ const replies = messages.filter((message) => {
38
+ if (message.role !== "assistant" || message.referenceOnly) return false;
39
+ if (Number.isFinite(message.id ?? message.seq) && Number.isFinite(cutoff)) {
40
+ const id = Number(message.id ?? message.seq);
41
+ if (
42
+ id >= cutoff &&
43
+ (!Number.isFinite(message.time) || message.time > now)
44
+ )
45
+ return false;
46
+ }
47
+ const targets = [
48
+ ...(Array.isArray(message.replyTargets) ? message.replyTargets : []),
49
+ ...(Array.isArray(message.replyTargetIds)
50
+ ? message.replyTargetIds.map((id) => byId.get(Number(id)))
51
+ : []),
52
+ ];
53
+ return targets.some((target) =>
54
+ speakers.has(String(target?.speaker ?? target?.userId ?? "")),
55
+ );
56
+ });
57
+ return [
58
+ ...batch,
59
+ ...replies.slice(-2).map((message) => ({
60
+ role: "assistant",
61
+ activityEvidence: true,
62
+ text: message.text || "",
63
+ })),
64
+ ];
65
+ }
@@ -1,5 +1,10 @@
1
1
  import { gameTopic } from "./intent.js";
2
- import { isExecutable, needsEnergy } from "./kinds.js";
2
+ import { isExecutable, needsEnergy, classifyActivity } from "./kinds.js";
3
+ import {
4
+ nextReadingChunk,
5
+ expectedTextTitle,
6
+ awaitsTextInput,
7
+ } from "./reading-input.js";
3
8
  export function unsupportedAction(task) {
4
9
  return task.activity === "unknown" &&
5
10
  /点.{0,8}外卖|下单|购买|实拍|拍照给|尝.{0,6}(?:味道|好吃)/.test(task.title)
@@ -18,6 +23,23 @@ export function executionBlock(time, task, now) {
18
23
  const life = time.search.life;
19
24
  const unsupported = unsupportedAction(task);
20
25
  if (unsupported) return unsupported;
26
+ if (
27
+ task.activity === "write" &&
28
+ awaitsTextInput(task.title) &&
29
+ classifyActivity(task.title) === "read"
30
+ )
31
+ return "等待来稿的阅读约定不能当成写作执行";
32
+ if (
33
+ task.checkpoint.awaitedText &&
34
+ (task.activity !== "read" || !nextReadingChunk(time.mind, task, now))
35
+ )
36
+ return "等待约定的正文或资料";
37
+ if (
38
+ task.activity === "read" &&
39
+ expectedTextTitle(task) &&
40
+ !nextReadingChunk(time.mind, task, now)
41
+ )
42
+ return "指定的正文还不可阅读";
21
43
  if (task.activity === "unknown" || !isExecutable(task.activity))
22
44
  return task.activity === "unknown"
23
45
  ? "需要把行动内容想具体"
@@ -1,130 +1,130 @@
1
- import { evidence, parse } from "../util.js";
2
- import { intentRecipient } from "./intent.js";
3
-
4
- export class DeliveryLinks {
5
- constructor(tasks) {
6
- this.tasks = tasks;
7
- this.time = tasks.time;
8
- this.db = tasks.db;
9
- }
10
- resolve(row, message, turn = {}, now = this.time.now()) {
11
- if (
12
- !/(?:发(?:给)?你|给你(?:看|读|发)|这就发|已经发)/.test(message.text || "")
13
- )
14
- return null;
15
- const questions = (message.replyTargetIds || turn.targetMessageIds || [])
16
- .map((id) =>
17
- this.db
18
- .prepare(
19
- "SELECT payload FROM core_events WHERE seq=? AND session_id=? AND role='user'",
20
- )
21
- .get(Number(id), row.session_id),
22
- )
23
- .filter(Boolean)
24
- .map((q) => parse(q.payload, {}).text || "");
25
- const cues = [message.text, ...questions].join(" ");
26
- const works = this.db
27
- .prepare(
28
- "SELECT w.*,v.sources,v.content FROM mind_time_works w JOIN mind_time_versions v ON v.work_id=w.id AND v.version=w.version WHERE w.state='complete' AND w.created<=? AND w.task_id IS NOT NULL ORDER BY w.updated DESC LIMIT 30",
29
- )
30
- .all(now)
31
- .map((w) => ({ ...w, sources: parse(w.sources, []) }))
32
- .filter(
33
- (w) =>
34
- this.time.visible(w, row.session_id, now) &&
35
- (!w.session_id || w.session_id === row.session_id),
36
- );
37
- const named = works.filter((w) => cues.includes(w.title)),
38
- candidates = named.length
39
- ? named
40
- : /小说|短篇|作品|诗|正文|札记/.test(cues)
41
- ? works
42
- : [];
43
- if (candidates.length !== 1) return null;
44
- const work = candidates[0],
45
- task = this.tasks.get(work.task_id);
46
- if (!task || task.state !== "done") return null;
47
- const subject = intentRecipient(this.db, {
48
- session: row.session_id,
49
- subject: turn.targetUserIds?.[0],
50
- title: message.text,
51
- sources: [row.seq],
52
- });
53
- if (!subject) return null;
54
- const share = this.db
55
- .prepare(
56
- "SELECT * FROM mind_time_shares WHERE work_id=? AND version=? AND session_id=?",
57
- )
58
- .get(work.id, work.version, row.session_id);
59
- const state =
60
- share?.state === "sent" && share.offset >= Array.from(work.content).length
61
- ? "sent"
62
- : share?.state === "sent" && share.delivery_kind === "report"
63
- ? "reported"
64
- : share?.state || "waiting";
65
- const sources = evidence([...task.sources, `m:${row.seq}`]);
66
- this.db
67
- .prepare(
68
- "UPDATE mind_time_tasks SET kind='promise',subject=?,sources=?,share_state=?,updated=? WHERE id=?",
69
- )
70
- .run(subject, JSON.stringify(sources), state, now, task.id);
71
- if (!task.sources.includes(`m:${row.seq}`))
72
- this.time.event(
73
- task.id,
74
- "delivery-linked",
75
- "分享承诺归回已有作品,未新增创作待办",
76
- { source: `m:${row.seq}`, workId: work.id, delivery: state },
77
- now,
78
- );
79
- return task.id;
80
- }
81
- reconcile(now = this.time.now()) {
82
- const fixed = [];
83
- for (const task of this.db
84
- .prepare(
85
- "SELECT * FROM mind_time_tasks WHERE activity='unknown' AND state IN ('todo','waiting') AND json_extract(checkpoint,'$.mergedInto') IS NULL",
86
- )
87
- .all()) {
88
- for (const ref of parse(task.sources, []).filter((r) =>
89
- r.startsWith("m:"),
90
- )) {
91
- const row = this.db
92
- .prepare(
93
- "SELECT seq,session_id,time,payload FROM core_events WHERE seq=? AND time<=?",
94
- )
95
- .get(Number(ref.slice(2)), now);
96
- if (!row) continue;
97
- const canonical = this.resolve(
98
- row,
99
- parse(row.payload, {}),
100
- { targetUserIds: task.subject ? [task.subject] : [] },
101
- now,
102
- );
103
- if (!canonical || canonical === task.id) continue;
104
- this.tasks.links.link("task", task.id, canonical, now);
105
- this.db
106
- .prepare(
107
- "UPDATE mind_time_tasks SET state='abandoned',checkpoint=?,wait_reason=?,revision=revision+1 WHERE id=?",
108
- )
109
- .run(
110
- JSON.stringify({
111
- ...parse(task.checkpoint, {}),
112
- mergedInto: canonical,
113
- }),
114
- "已归入已有作品的交付记录",
115
- task.id,
116
- );
117
- this.time.event(
118
- task.id,
119
- "merged",
120
- "交付有真实作品与送达记录,归回原事项",
121
- { taskId: canonical },
122
- now,
123
- );
124
- fixed.push({ from: task.id, to: canonical });
125
- break;
126
- }
127
- }
128
- return fixed;
129
- }
130
- }
1
+ import { evidence, parse } from "../util.js";
2
+ import { intentRecipient } from "./intent.js";
3
+
4
+ export class DeliveryLinks {
5
+ constructor(tasks) {
6
+ this.tasks = tasks;
7
+ this.time = tasks.time;
8
+ this.db = tasks.db;
9
+ }
10
+ resolve(row, message, turn = {}, now = this.time.now()) {
11
+ if (
12
+ !/(?:发(?:给)?你|给你(?:看|读|发)|这就发|已经发)/.test(message.text || "")
13
+ )
14
+ return null;
15
+ const questions = (message.replyTargetIds || turn.targetMessageIds || [])
16
+ .map((id) =>
17
+ this.db
18
+ .prepare(
19
+ "SELECT payload FROM core_events WHERE seq=? AND session_id=? AND role='user'",
20
+ )
21
+ .get(Number(id), row.session_id),
22
+ )
23
+ .filter(Boolean)
24
+ .map((q) => parse(q.payload, {}).text || "");
25
+ const cues = [message.text, ...questions].join(" ");
26
+ const works = this.db
27
+ .prepare(
28
+ "SELECT w.*,v.sources,v.content FROM mind_time_works w JOIN mind_time_versions v ON v.work_id=w.id AND v.version=w.version WHERE w.state='complete' AND w.created<=? AND w.task_id IS NOT NULL ORDER BY w.updated DESC LIMIT 30",
29
+ )
30
+ .all(now)
31
+ .map((w) => ({ ...w, sources: parse(w.sources, []) }))
32
+ .filter(
33
+ (w) =>
34
+ this.time.visible(w, row.session_id, now) &&
35
+ (!w.session_id || w.session_id === row.session_id),
36
+ );
37
+ const named = works.filter((w) => cues.includes(w.title)),
38
+ candidates = named.length
39
+ ? named
40
+ : /小说|短篇|作品|诗|正文|札记/.test(cues)
41
+ ? works
42
+ : [];
43
+ if (candidates.length !== 1) return null;
44
+ const work = candidates[0],
45
+ task = this.tasks.get(work.task_id);
46
+ if (!task || task.state !== "done") return null;
47
+ const subject = intentRecipient(this.db, {
48
+ session: row.session_id,
49
+ subject: turn.targetUserIds?.[0],
50
+ title: message.text,
51
+ sources: [row.seq],
52
+ });
53
+ if (!subject) return null;
54
+ const share = this.db
55
+ .prepare(
56
+ "SELECT * FROM mind_time_shares WHERE work_id=? AND version=? AND session_id=?",
57
+ )
58
+ .get(work.id, work.version, row.session_id);
59
+ const state =
60
+ share?.state === "sent" && share.offset >= Array.from(work.content).length
61
+ ? "sent"
62
+ : share?.state === "sent" && share.delivery_kind === "report"
63
+ ? "reported"
64
+ : share?.state || "waiting";
65
+ const sources = evidence([...task.sources, `m:${row.seq}`]);
66
+ this.db
67
+ .prepare(
68
+ "UPDATE mind_time_tasks SET kind='promise',subject=?,sources=?,share_state=?,updated=? WHERE id=?",
69
+ )
70
+ .run(subject, JSON.stringify(sources), state, now, task.id);
71
+ if (!task.sources.includes(`m:${row.seq}`))
72
+ this.time.event(
73
+ task.id,
74
+ "delivery-linked",
75
+ "分享承诺归回已有作品,未新增创作待办",
76
+ { source: `m:${row.seq}`, workId: work.id, delivery: state },
77
+ now,
78
+ );
79
+ return task.id;
80
+ }
81
+ reconcile(now = this.time.now()) {
82
+ const fixed = [];
83
+ for (const task of this.db
84
+ .prepare(
85
+ "SELECT * FROM mind_time_tasks WHERE activity='unknown' AND state IN ('todo','waiting') AND json_extract(checkpoint,'$.mergedInto') IS NULL",
86
+ )
87
+ .all()) {
88
+ for (const ref of parse(task.sources, []).filter((r) =>
89
+ r.startsWith("m:"),
90
+ )) {
91
+ const row = this.db
92
+ .prepare(
93
+ "SELECT seq,session_id,time,payload FROM core_events WHERE seq=? AND time<=?",
94
+ )
95
+ .get(Number(ref.slice(2)), now);
96
+ if (!row) continue;
97
+ const canonical = this.resolve(
98
+ row,
99
+ parse(row.payload, {}),
100
+ { targetUserIds: task.subject ? [task.subject] : [] },
101
+ now,
102
+ );
103
+ if (!canonical || canonical === task.id) continue;
104
+ this.tasks.links.link("task", task.id, canonical, now);
105
+ this.db
106
+ .prepare(
107
+ "UPDATE mind_time_tasks SET state='abandoned',checkpoint=?,wait_reason=?,revision=revision+1 WHERE id=?",
108
+ )
109
+ .run(
110
+ JSON.stringify({
111
+ ...parse(task.checkpoint, {}),
112
+ mergedInto: canonical,
113
+ }),
114
+ "已归入已有作品的交付记录",
115
+ task.id,
116
+ );
117
+ this.time.event(
118
+ task.id,
119
+ "merged",
120
+ "交付有真实作品与送达记录,归回原事项",
121
+ { taskId: canonical },
122
+ now,
123
+ );
124
+ fixed.push({ from: task.id, to: canonical });
125
+ break;
126
+ }
127
+ }
128
+ return fixed;
129
+ }
130
+ }
@@ -7,6 +7,7 @@ import { ACTIVITY_LABELS } from "./tasks.js";
7
7
  import { activityLabel, isPluginActivity, needsEnergy } from "./kinds.js";
8
8
  import { stepPlugin } from "./plugin-activity.js";
9
9
  import { executionBlock } from "./availability.js";
10
+ import { nextReadingChunk } from "./reading-input.js";
10
11
 
11
12
  const ACTIVITY_PROMPT =
12
13
  '执行一个属于自己的实际活动步骤。write创作一个完整小段,已有草稿时只追加下一段,修改模式则提供修改后的正文;根据篇幅决定本篇是否结束,连载完成的是本章。read只能阅读input.reading实际提供的段落;think留下想法记录。不编造外部操作、游玩、通关或未提供的资料。虚构人物只属于作品,不能进入真实人物记忆。不输出隐藏推理。外部suggestion是别人的建议,你决定采纳或拒绝并保留理由,可以改变方向、暂停或完结项目。输出JSON {"done":false,"title":"标题","content":"最多1800字完整小段","summary":"情节摘要","next":"续接位置","bible":{"characters":"人物设定","world":"世界设定","threads":"未解决线索"},"feeling":{"feeling":"两三个字的心情","valence":0.2},"accepted":true,"reason":"自己的选择理由","projectState":"active|paused|complete","share":{"choice":"send|later|decline","reason":"分享选择"}}。完成本篇时done:true;休息/拒绝时没有content并说明reason。本步骤不发消息、不直接改变人格。';
@@ -149,8 +150,9 @@ export class TimeExecutor {
149
150
  }
150
151
  }
151
152
  chunk =
152
- candidate.checkpoint.pendingStep?.chunk ||
153
- (candidate.activity === "read" ? mind.reading.next(now) : null);
153
+ candidate.activity === "read"
154
+ ? nextReadingChunk(mind, candidate, now)
155
+ : candidate.checkpoint.pendingStep?.chunk || null;
154
156
  if (candidate.activity === "read" && !chunk) {
155
157
  time.tasks.wait(candidate, "书架没有可读资料", now, now);
156
158
  continue;
@@ -4,6 +4,7 @@ import { evidence, text, parse } from "../util.js";
4
4
  import { gameTopic, intentUnit } from "./intent.js";
5
5
  import { naturalGameReason, gameContract, gameText } from "./presentation.js";
6
6
  import { gameName } from "./availability.js";
7
+ import { belongsToGame } from "./material-relevance.js";
7
8
 
8
9
  export class Games {
9
10
  constructor(time) {
@@ -93,7 +94,7 @@ export class Games {
93
94
  "SELECT title,content,kind,uncertainty FROM mind_time_sources WHERE id=? AND project_id=?",
94
95
  )
95
96
  .get(task.checkpoint.sourceIds[0], task.project_id);
96
- if (!source) return null;
97
+ if (!source || !belongsToGame(source, this.topic(task))) return null;
97
98
  const active = this.time.clock.view(task, now).progress;
98
99
  const window = task.checkpoint.readWindow;
99
100
  const progress = window
@@ -229,11 +230,7 @@ export class Games {
229
230
  " 游戏 剧情 资料 " +
230
231
  (contract?.unit?.label || "") +
231
232
  " " +
232
- (checkpoint.segment
233
- ? `主题片段 ${checkpoint.segment + 1}`
234
- : contract
235
- ? "内容与第一印象"
236
- : "简介"),
233
+ (contract ? "内容与第一印象" : "剧情与角色"),
237
234
  {
238
235
  projectId: project.id,
239
236
  now,
@@ -247,7 +244,9 @@ export class Games {
247
244
  if (!time.valid(task))
248
245
  return { status: "cancelled", reason: "资料返回时任务已经变化" };
249
246
  const seen = new Set(checkpoint.seenSources || []),
250
- fresh = sources.filter((s) => !seen.has(s.id));
247
+ fresh = sources.filter(
248
+ (s) => !seen.has(s.id) && belongsToGame(s, topic),
249
+ );
251
250
  if (!fresh.length) {
252
251
  time.tasks.wait(
253
252
  task,
@@ -369,9 +368,15 @@ export class Games {
369
368
  )
370
369
  .get(id, project.id),
371
370
  )
372
- .filter(Boolean);
371
+ .filter((s) => s && belongsToGame(s, topic));
373
372
  if (!sources.length) {
374
373
  time.tasks.wait(task, "本段资料已不可用", now + 3600000, now);
374
+ const next = { ...checkpoint, sourceIds: [], stage: "search" };
375
+ delete next.readWindow;
376
+ delete next.activityClock;
377
+ this.db
378
+ .prepare("UPDATE mind_time_tasks SET checkpoint=? WHERE id=?")
379
+ .run(JSON.stringify(next), task.id);
375
380
  return { status: "waiting", reason: "资料不可用" };
376
381
  }
377
382
  const prompt =
@@ -298,8 +298,8 @@ export class TimeSystem {
298
298
  }
299
299
  visible(row, session, now = this.now()) {
300
300
  if (session === undefined) return true;
301
- if (!this.allowed(row, now)) return false;
302
301
  if (row.discretion !== "open" && row.session_id !== session) return false;
302
+ if (!this.allowed(row, now)) return false;
303
303
  return this.mind.meetings.stays(
304
304
  { ...row, sources: evidence(row.sources) },
305
305
  session,
@@ -422,7 +422,7 @@ export class TimeSystem {
422
422
  }),
423
423
  };
424
424
  }
425
- view({ session, now = this.now(), cue = [] } = {}) {
425
+ view({ session, now = this.now(), cue = [], activityCue = cue } = {}) {
426
426
  const primary = this.primary(),
427
427
  task = primary?.created <= now ? primary : null;
428
428
  let current =
@@ -548,6 +548,11 @@ export class TimeSystem {
548
548
  plannedFor: this.planView(row),
549
549
  })),
550
550
  works: this.works.fragments({ session, now, cue }),
551
+ activityRecall: this.works.activityEvidence({
552
+ session,
553
+ now,
554
+ cue: activityCue,
555
+ }),
551
556
  ...(() => {
552
557
  const actions = this.db
553
558
  .prepare(
@@ -730,9 +735,12 @@ export class TimeSystem {
730
735
  ? task.share_state
731
736
  : "none",
732
737
  activityKind: task.activity === "game" ? "gaming" : task.activity,
738
+ ...(task.activity === "game" ? { experienceMode: "reference" } : {}),
733
739
  progress:
734
740
  task.activity === "game"
735
- ? `玩过第 ${task.checkpoint.segment || 0} 段,留下自己的游玩记录`
741
+ ? work?.title
742
+ ? `已留下《${work.title}》这篇阅读笔记`
743
+ : "已接触一些游戏资料"
736
744
  : `${work?.title || ""},已保存 ${work?.characters || 0} 字${state === "done" ? "完成稿" : "正文"}`,
737
745
  project: task.project_id,
738
746
  work: work?.id || null,
@@ -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 }) ||