luckytri 1.0.8 → 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 (38) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/en/CHANGELOG.md +35 -24
  4. package/docs/en/guide.md +3 -1
  5. package/docs/zh/CHANGELOG.md +35 -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 +0 -8
  15. package/server/core/conversation-grounding.js +247 -4
  16. package/server/core/dialogue-context.js +16 -13
  17. package/server/core/dialogue-contract.js +33 -9
  18. package/server/core/initiative-context.js +36 -16
  19. package/server/core/model-json.js +36 -3
  20. package/server/core/model-manager.js +2 -2
  21. package/server/core/model-presets.js +169 -24
  22. package/server/core/persona-manager.js +8 -3
  23. package/server/core/reader-check.js +130 -26
  24. package/server/core/reply-delivery.js +152 -11
  25. package/server/core/repository.js +33 -0
  26. package/server/core/response-generator.js +12 -3
  27. package/server/core/response-validator.js +18 -3
  28. package/server/core/turn-processor.js +26 -4
  29. package/server/core/turn.js +258 -44
  30. package/server/i18n/decisions.js +16 -0
  31. package/server/i18n/errors.js +2 -0
  32. package/server/i18n/models.js +11 -2
  33. package/server/mind/expression-grounding.js +5 -2
  34. package/server/mind/time/activity-recall.js +65 -0
  35. package/server/mind/time/index.js +6 -2
  36. package/server/mind/time/tasks.js +8 -10
  37. package/server/mind/time/works.js +3 -2
  38. package/server/mind/view.js +9 -2
@@ -9,10 +9,25 @@ import {
9
9
  } from "./response-validator.js";
10
10
  import { deliver, sleep } from "./message-scheduler.js";
11
11
  import { leaks } from "../mind/guard.js";
12
- import { conversationGrounding } from "./conversation-grounding.js";
12
+ import { interestTerms } from "../mind/attention.js";
13
+ import {
14
+ bubblePreference,
15
+ conversationGrounding,
16
+ } from "./conversation-grounding.js";
13
17
  import { initiativeAudience } from "../mind/conversation-origin.js";
14
- import { readerQuestions, READER_CHECK, INTENT_CHECK } from "./reader-check.js";
18
+ import {
19
+ readerQuestions,
20
+ readerCheckPrompt,
21
+ INTENT_CHECK,
22
+ } from "./reader-check.js";
15
23
  import { currentExchange } from "./dialogue-context.js";
24
+ import {
25
+ claimsLivedAction,
26
+ EXPRESSION_GROUNDING,
27
+ } from "../mind/expression-grounding.js";
28
+
29
+ const replyFormatFeedback =
30
+ /(?:看着|看起来|读起来).{0,8}(?:累|太长|太多)|(?:你|你们).{0,12}(?:可以|能不能|别|不要).{0,12}(?:一段|一条|一句|合在一起|拆|少发|少说)|(?:一小节|一段|一条|一句).{0,12}(?:发|说|回|拆|累)|(?:太抽象|太人机|太机械|复读|看不懂|说人话|别拆|不要拆)/;
16
31
  function isFormatError(error) {
17
32
  return (
18
33
  error instanceof SyntaxError ||
@@ -22,6 +37,48 @@ function isFormatError(error) {
22
37
  );
23
38
  }
24
39
 
40
+ export function filterTranscriptContradictedIssues(issues, snapshot) {
41
+ const messages = (snapshot.messages || []).filter(
42
+ (message) => message.role === "user",
43
+ );
44
+ const transcriptTerms = interestTerms(
45
+ messages.map((message) => message.text),
46
+ );
47
+ const absentEvidence =
48
+ /(?:当前|现有|此前|前文|对话|上下文|原话|原文|消息|记录).{0,20}(?:没有|未|并没有|不在)|(?:没有|并没有|未|无人).{0,24}(?:这句话|这句|提到|说过|出现|提供|给出|片段|原文|上下文|记录)/u;
49
+ const quotedPhrases = (issue) => {
50
+ const phrases = [];
51
+ for (const [open, close] of [
52
+ ["“", "”"],
53
+ ["‘", "’"],
54
+ ["「", "」"],
55
+ ["『", "』"],
56
+ ['"', '"'],
57
+ ]) {
58
+ let from = 0;
59
+ while (from < issue.length) {
60
+ const start = issue.indexOf(open, from);
61
+ if (start < 0) break;
62
+ const end = issue.indexOf(close, start + open.length);
63
+ if (end < 0) break;
64
+ const phrase = issue.slice(start + open.length, end).trim();
65
+ if (phrase.length >= 3) phrases.push(phrase);
66
+ from = start + open.length;
67
+ }
68
+ }
69
+ return phrases;
70
+ };
71
+ return (issues || []).filter((issue) => {
72
+ if (!absentEvidence.test(issue)) return true;
73
+ return !quotedPhrases(issue).some((phrase) => {
74
+ if (messages.some((message) => (message.text || "").includes(phrase)))
75
+ return true;
76
+ const terms = interestTerms([phrase]);
77
+ return [...terms].filter((term) => transcriptTerms.has(term)).length >= 2;
78
+ });
79
+ });
80
+ }
81
+
25
82
  // Owns this part of the lifecycle; the facade keeps the shared runtime state.
26
83
  export class ReplyDelivery {
27
84
  constructor(owner) {
@@ -55,7 +112,10 @@ export class ReplyDelivery {
55
112
  (m) =>
56
113
  c.privateChat ||
57
114
  targetUsers.has(m.userId) ||
58
- (m.replyId && snapshot.batch.some((b) => b.platformId === m.replyId)),
115
+ m.relation === "direct" ||
116
+ (m.replyId &&
117
+ snapshot.batch.some((b) => b.platformId === m.replyId)) ||
118
+ replyFormatFeedback.test(m.text || ""),
59
119
  );
60
120
  const audienceCurrent = () =>
61
121
  !snapshot.initiative ||
@@ -135,7 +195,15 @@ export class ReplyDelivery {
135
195
  issues,
136
196
  { plain },
137
197
  );
138
- return normalizeResponse(raw, turn, fallbackText);
198
+ return normalizeResponse(
199
+ raw,
200
+ {
201
+ ...turn,
202
+ maxBubbles:
203
+ bubblePreference(snapshot) === "single" ? 1 : turn.maxBubbles,
204
+ },
205
+ fallbackText,
206
+ );
139
207
  } catch (error) {
140
208
  if (!isFormatError(error)) throw error;
141
209
  if (snapshot.initiative) throw error;
@@ -183,7 +251,22 @@ export class ReplyDelivery {
183
251
  );
184
252
  const needsDeepCheck = (response) =>
185
253
  readerQuestions(snapshot, turn).length > 0 ||
254
+ (turn.contribution?.kind === "question" &&
255
+ snapshot.messages
256
+ .filter((m) => m.role === "assistant")
257
+ .slice(-3)
258
+ .some((m) => /[??]/.test(m.text || ""))) ||
186
259
  currentExchange(snapshot).thirdPartyClaims.length > 0 ||
260
+ response.bubbles.some((line) =>
261
+ /你(?:的|在|给|付|掏).{0,12}(?:账单|费用|钱|token)|(?:烧|花|扣|付).{0,6}你.{0,6}(?:钱|token)|账单.{0,8}你/i.test(
262
+ line,
263
+ ),
264
+ ) ||
265
+ response.bubbles.some((line) =>
266
+ /我.{0,20}(?:只(?:能|有)|才(?:能|有)).{0,15}(?:回|输入|消息|开口)|(?:没人|没有人).{0,12}(?:递话|说话|叫我).{0,12}(?:我.{0,5})?(?:不会|不|才)/.test(
267
+ line,
268
+ ),
269
+ ) ||
187
270
  response.bubbles.some((line) =>
188
271
  /(?:我|刚才|刚刚|刚还|今天|昨天|之前).{0,25}(?:读过|读了|读完|翻开|在翻|写完|完成了|看完了)/.test(
189
272
  line,
@@ -250,6 +333,35 @@ export class ReplyDelivery {
250
333
  snapshot.batch.some((m) => m.relation === "unresolved")))));
251
334
  const deepCheck = async (response) => {
252
335
  try {
336
+ // An old expression can already contain a fictional action. Check
337
+ // the actual words again against activity records, without giving
338
+ // that draft or its own earlier explanations the role of evidence.
339
+ if (
340
+ snapshot.initiative &&
341
+ claimsLivedAction(response.bubbles.join("\n")) &&
342
+ !(
343
+ trace.experienceReview?.ok === true &&
344
+ trace.experienceReview.words.join("\n") ===
345
+ response.bubbles.join("\n")
346
+ )
347
+ ) {
348
+ const lived = await models.call(
349
+ model,
350
+ "validation",
351
+ EXPRESSION_GROUNDING,
352
+ {
353
+ candidate: { words: response.bubbles },
354
+ currentLife: snapshot.inner?.currentLife || {},
355
+ notes: [],
356
+ self: [],
357
+ },
358
+ trace,
359
+ );
360
+ if (lived.ok !== true)
361
+ return [
362
+ `主动消息声称的新行动尚无实际活动记录支持:${lived.reason || "没有核实该行动"}`,
363
+ ];
364
+ }
253
365
  const checked = await models.call(
254
366
  model,
255
367
  "validation",
@@ -263,7 +375,7 @@ export class ReplyDelivery {
263
375
  },
264
376
  response,
265
377
  experienceEvidence:
266
- "自己的旧回复可能误报经历,不单独证明实际读过、做完或正在做。声称具体活动须核对currentLife的真实作品、阅读与动作证据;待办和愿望不能证明执行。自己此刻的想法与感受不需要外部动作证明。普通问候不用补答已经换了话题的旧任务。",
378
+ "自己的旧回复与expression草稿都可能误报经历,不单独证明实际读过、做完或正在做。sourceMaterial里的earlier_thought只证明当时想过。声称具体活动须核对currentLife的真实作品、阅读与动作证据;待办和愿望不能证明执行。自己此刻的想法与感受不需要外部动作证明。普通问候不用补答已经换了话题的旧任务。",
267
379
  replyFocus: replyFocus(snapshot, turn),
268
380
  thirdPartyClaims: currentExchange(snapshot).thirdPartyClaims,
269
381
  imageEvidence: c.generationImages.length
@@ -320,9 +432,10 @@ export class ReplyDelivery {
320
432
  const reader = await models.call(
321
433
  model,
322
434
  "validation",
323
- READER_CHECK,
435
+ readerCheckPrompt(turn),
324
436
  {
325
437
  questions: readerQuestions(snapshot, turn),
438
+ choice: turn.choice,
326
439
  reply: response.bubbles,
327
440
  },
328
441
  trace,
@@ -332,10 +445,14 @@ export class ReplyDelivery {
332
445
  (issue) => `给提问者的解释仍没说清:${issue}`,
333
446
  );
334
447
  }
335
- return checked.ok
448
+ const issues = filterTranscriptContradictedIssues(
449
+ checked.issues,
450
+ snapshot,
451
+ );
452
+ return checked.ok || (checked.issues.length > 0 && !issues.length)
336
453
  ? []
337
- : checked.issues.length
338
- ? checked.issues
454
+ : issues.length
455
+ ? issues
339
456
  : ["与天性或语境不一致"];
340
457
  } catch (error) {
341
458
  trace.steps.push(
@@ -359,16 +476,18 @@ export class ReplyDelivery {
359
476
  if (outdated()) return staleExit();
360
477
  issues = await deepCheck(response);
361
478
  }
362
- const rewriteLimit = snapshot.initiative || c.pressure >= 0.85 ? 1 : 2;
479
+ const rewriteLimit = c.pressure >= 0.85 ? 1 : 2;
480
+ const repairConstraints = new Set();
363
481
  for (let attempt = 0; issues.length && attempt < rewriteLimit; attempt++) {
364
482
  (trace.revisions ||= []).push({
365
483
  draft: response.bubbles,
366
484
  issues: [...issues],
367
485
  });
368
486
  trace.validation = issues;
487
+ for (const issue of issues) repairConstraints.add(issue);
369
488
  if (outdated()) return staleExit();
370
489
  response = await makeResponse(
371
- issues,
490
+ [...repairConstraints],
372
491
  response,
373
492
  !snapshot.initiative && attempt === rewriteLimit - 1,
374
493
  );
@@ -378,6 +497,28 @@ export class ReplyDelivery {
378
497
  issues = await deepCheck(response);
379
498
  }
380
499
  }
500
+ // Deleting an unnecessary final bubble can retain a valid answer. It
501
+ // never bypasses factual, privacy or comprehension checks, and crisis
502
+ // responses keep their complete support rather than being shortened.
503
+ if (issues.length && response.bubbles.length > 1 && !turn.crisis?.clear) {
504
+ for (let count = response.bubbles.length - 1; count > 0; count--) {
505
+ const prefix = {
506
+ ...response,
507
+ bubbles: response.bubbles.slice(0, count),
508
+ };
509
+ const remaining = check(prefix);
510
+ if (remaining.length) continue;
511
+ if (outdated()) return staleExit();
512
+ const reviewed = needsDeepCheck(prefix) ? await deepCheck(prefix) : [];
513
+ if (reviewed.length) continue;
514
+ response = prefix;
515
+ issues = [];
516
+ trace.steps.push(
517
+ "删去未通过检查的多余末尾气泡,保留已通过全部校验的原答复",
518
+ );
519
+ break;
520
+ }
521
+ }
381
522
  // Removing a rejected suffix is a mechanical repair with no new factual
382
523
  // claims. Preserve the answer, then run every check again.
383
524
  if (issues.some((issue) => issue.startsWith("不要把hh反复当句尾装饰"))) {
@@ -30,6 +30,39 @@ export function migrateCore(store) {
30
30
  CREATE TABLE IF NOT EXISTS core_context_summaries (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, level INTEGER NOT NULL, first_seq INTEGER NOT NULL, last_seq INTEGER NOT NULL, first_time INTEGER, last_time INTEGER, created INTEGER NOT NULL, data TEXT NOT NULL);
31
31
  CREATE INDEX IF NOT EXISTS core_context_summaries_session ON core_context_summaries(session_id,first_seq);
32
32
  `);
33
+ const savedModelsRow = db
34
+ .prepare("SELECT value FROM core_config WHERE id=?")
35
+ .get("models");
36
+ if (savedModelsRow) {
37
+ let savedModels;
38
+ try {
39
+ savedModels = JSON.parse(savedModelsRow.value);
40
+ } catch {
41
+ savedModels = null;
42
+ }
43
+ if (Array.isArray(savedModels)) {
44
+ const unavailablePresetIds = new Set([
45
+ "opencode-zen-space-bunny-free",
46
+ "opencode-zen-step-5-preview-free",
47
+ ]);
48
+ const unavailableZenModels = new Set([
49
+ "space-bunny-free",
50
+ "step-5-preview-free",
51
+ ]);
52
+ const availableModels = savedModels.filter(
53
+ (model) =>
54
+ !(
55
+ unavailablePresetIds.has(model?.id) ||
56
+ (model?.provider === "opencode-zen" &&
57
+ unavailableZenModels.has(model?.model))
58
+ ),
59
+ );
60
+ if (availableModels.length !== savedModels.length)
61
+ db.prepare(
62
+ "UPDATE core_config SET value=?,version=version+1 WHERE id=?",
63
+ ).run(JSON.stringify(availableModels), "models");
64
+ }
65
+ }
33
66
  // A saved persona or prompt may intentionally use an older name. Leave it
34
67
  // intact; fresh workspaces receive the LuckyTri defaults from the store.
35
68
  if (!db.prepare("SELECT id FROM core_config WHERE id='migration-v1'").get()) {
@@ -2,6 +2,7 @@ import { replyFocus } from "./conversation-cues.js";
2
2
  import { wordingNotes } from "./turn.js";
3
3
  import { initiativeContext } from "./initiative-context.js";
4
4
  import { currentExchange, dialogueContext } from "./dialogue-context.js";
5
+ import { bubblePreference } from "./conversation-grounding.js";
5
6
 
6
7
  // Puts an already chosen answer into words again: used when the turn came
7
8
  // back without words or the first draft failed a check.
@@ -76,14 +77,22 @@ export async function generate(
76
77
  issues,
77
78
  ...(explain
78
79
  ? {
79
- explanationTask:
80
- "重新读current和相关原话,依据确实提供的记录,用最直接的一两句回答当前问题或表达自己的具体反应。不维护旧解释,不加入无关旧事,不把修正写成认错流程或‘没理清’的状态报告;确实缺事实时说清缺的是哪部分。解释故事不把作者本人当小说人物。",
80
+ explanationTask: ["feeling", "greeting"].includes(
81
+ decision.understanding?.kind,
82
+ )
83
+ ? "重新读current。这轮是在表达感受或亲近,用自己此刻真实想说的一两句回应这个人;不分析他的措辞,不宣布接收了什么,不催他交代旧进度,也不用新的问题填空。可以轻松,也可以保留自己的态度,不要求迎合或表演亲密。"
84
+ : decision.understanding?.kind === "proposal"
85
+ ? "重新读current的新提议。表达自己的真实兴趣、顾虑或一个具体点子;没有说要改编旧作,就不要替对方定成改编或维护旧稿排期。愿不愿意由自己选择,不需要承诺交付或让对方先定方案。"
86
+ : "重新读current和相关原话,依据确实提供的记录,用最直接的一两句回答当前问题或表达自己的具体反应。不维护旧解释,不加入无关旧事,不把修正写成认错流程或‘没理清’的状态报告;确实缺事实时说清缺的是哪部分。解释故事不把作者本人当小说人物。",
81
87
  }
82
88
  : {}),
83
89
  imageGuide,
84
90
  replyFocus: replyFocus(snapshot, decision),
85
91
  guidance: wordingNotes(decision, snapshot),
86
- maxBubbles: decision.maxBubbles ?? 2,
92
+ maxBubbles:
93
+ bubblePreference(snapshot) === "single"
94
+ ? 1
95
+ : (decision.maxBubbles ?? 3),
87
96
  ...(!snapshot.initiative ? { exchange: currentExchange(snapshot) } : {}),
88
97
  },
89
98
  trace,
@@ -13,6 +13,7 @@ import {
13
13
  recentOwnMessages,
14
14
  routineSocialReply,
15
15
  } from "./conversation-grounding.js";
16
+ import { joinBubbleParts } from "./turn.js";
16
17
  const norm = (s) => s.toLowerCase().replace(/[\s\p{P}\p{S}]/gu, "");
17
18
  export function wrongIdentityClaim(text, snapshot) {
18
19
  const name = snapshot.persona?.name;
@@ -118,7 +119,17 @@ export function attributesOwnWordsToThem(text, snapshot) {
118
119
  // The reviewer judges one reply; it needs the turn and its recent lead-in,
119
120
  // not the summaries, recall or the rest of the transcript.
120
121
  export function reviewContext(snapshot, decision = {}, response = {}) {
121
- if (snapshot.initiative) return initiativeContext(snapshot);
122
+ if (snapshot.initiative) {
123
+ const { self: _self, inner, ...context } = initiativeContext(snapshot);
124
+ // Wishes, moods and impressions explain why she wants to make contact;
125
+ // they cannot establish what this recipient actually said or did.
126
+ return {
127
+ ...context,
128
+ relationships: inner?.relationships,
129
+ continuity: inner?.continuity,
130
+ currentLife: inner?.currentLife,
131
+ };
132
+ }
122
133
  const {
123
134
  persona: _persona,
124
135
  sourceRows: _sourceRows,
@@ -187,8 +198,12 @@ export function normalizeResponse(result, decision, fallback = "嗯") {
187
198
  bubbles = bubbles
188
199
  .filter((x) => typeof x === "string")
189
200
  .map((x) => x.trim())
190
- .filter(Boolean)
191
- .slice(0, maxBubbles);
201
+ .filter(Boolean);
202
+ if (bubbles.length > maxBubbles)
203
+ bubbles =
204
+ maxBubbles === 1
205
+ ? [joinBubbleParts(bubbles)]
206
+ : bubbles.slice(0, maxBubbles);
192
207
  if (!bubbles.length) bubbles = [fallback];
193
208
  return {
194
209
  ...(result && typeof result === "object" ? result : {}),
@@ -1,6 +1,7 @@
1
1
  import { parseSessionKey } from "../channels/session-key.js";
2
2
  import { initiativeContext, initiativeSnapshot } from "./initiative-context.js";
3
3
  import { withFallback } from "./model-manager.js";
4
+ import { activityRecallCue } from "../mind/time/activity-recall.js";
4
5
  import { replyPrompt, prompts } from "./persona-manager.js";
5
6
  import { buildContext, perceive } from "./context-builder.js";
6
7
  import {
@@ -195,15 +196,23 @@ export class TurnProcessor {
195
196
  this.owner.models,
196
197
  this.owner.fallbackFor(policy, model, trace),
197
198
  );
198
- resolved ||= perceive(
199
+ resolved = perceive(
199
200
  this.owner.repo,
200
201
  session,
201
202
  watermark,
202
203
  eventMode,
203
204
  nature.name,
205
+ { resolved, sentBefore: live ? now : 0 },
204
206
  );
205
207
  const batchIds = batch.map((m) => m.seq);
206
208
  const window = resolved.filter((m) => !m.referenceOnly).slice(-60);
209
+ const cues = batch.map((m) => ({
210
+ id: m.seq,
211
+ role: m.role,
212
+ speaker: m.userId,
213
+ userId: m.userId,
214
+ text: m.text || "",
215
+ }));
207
216
  // The speakers in this batch are the people whose history matters now.
208
217
  // Pulling everyone from a long room window made unrelated plans compete
209
218
  // with the exchange actually taking place.
@@ -266,14 +275,27 @@ export class TurnProcessor {
266
275
  kind: privateChat ? "private" : "group",
267
276
  people,
268
277
  cue: batch.map((m) => ({
269
- role: m.role,
270
- userId: m.userId,
271
- text: m.text || "",
278
+ ...cues.find((row) => row.id === m.seq),
272
279
  relation: resolved.find((line) => line.id === m.seq)?.relation,
273
280
  mentioned: m.mentioned === true,
274
281
  })),
275
282
  now,
276
283
  });
284
+ const botSpeakers = new Set(
285
+ (view.inner?.relationships?.known || [])
286
+ .filter((relationship) => relationship.kind === "bot")
287
+ .map((relationship) => String(relationship.subjectId)),
288
+ );
289
+ const activityCue = activityRecallCue(cues, window, {
290
+ excludedSpeakers: botSpeakers,
291
+ });
292
+ if (activityCue !== cues)
293
+ view.inner.currentLife = this.owner.mind.time.view({
294
+ session: preview?.viewSession || session,
295
+ now,
296
+ cue: cues,
297
+ activityCue,
298
+ });
277
299
  const snapshot = ownInitiative
278
300
  ? initiativeSnapshot(session, window, nature, now, policy.timeZone)
279
301
  : buildContext(