@musnows/scriverse 0.9.4 → 0.9.5

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.
package/dist/store.js CHANGED
@@ -14,6 +14,8 @@ import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
16
16
  import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
17
+ import { chapterAnnotationLineHashes, createChapterLineIds, MAX_CHAPTER_LINE_IDS, parseChapterAnnotationLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds, reconcileChapterLineIds, reanchorChapterAnnotations } from "./chapter-annotation-anchor.js";
18
+ import { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
17
19
  const WORK_LIST_BATCH_SIZE = 500;
18
20
  const ENTITY_LIST_BATCH_SIZE = 400;
19
21
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
@@ -534,6 +536,8 @@ export class Store {
534
536
  language: entity.language,
535
537
  coverUrl: entity.coverUrl,
536
538
  tags: entity.tags,
539
+ editorAutoIndentEnabled: entity.editorAutoIndentEnabled,
540
+ editorTypewriterModeEnabled: entity.editorTypewriterModeEnabled,
537
541
  ownerUserId: entity.ownerUserId
538
542
  };
539
543
  if (type === "volume")
@@ -802,8 +806,9 @@ export class Store {
802
806
  return this.db.transaction(() => {
803
807
  const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
804
808
  const timestamp = now();
805
- this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, version_no, created_at, updated_at, owner_user_id)
806
- VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), timestamp, timestamp, ownerUserId);
809
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
810
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, version_no, created_at, updated_at, owner_user_id)
811
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), snapshot.editorAutoIndentEnabled === true ? 1 : 0, snapshot.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, ownerUserId);
807
812
  if (ownerUserId !== SYSTEM_USER_ID) {
808
813
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
809
814
  }
@@ -868,8 +873,9 @@ export class Store {
868
873
  const timestamp = now();
869
874
  const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
870
875
  this.db.transaction(() => {
871
- this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, created_at, updated_at, owner_user_id)
872
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), timestamp, timestamp, resolvedOwnerUserId);
876
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
877
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, created_at, updated_at, owner_user_id)
878
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), input.editorAutoIndentEnabled === true ? 1 : 0, input.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, resolvedOwnerUserId);
873
879
  if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
874
880
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, resolvedOwnerUserId, resolvedOwnerUserId, timestamp);
875
881
  }
@@ -1201,8 +1207,9 @@ export class Store {
1201
1207
  const current = this.getWork(workId);
1202
1208
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
1203
1209
  const timestamp = now();
1204
- this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, version_no = version_no + 1, updated_at = ?
1205
- WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), timestamp, workId);
1210
+ this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
1211
+ editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, version_no = version_no + 1, updated_at = ?
1212
+ WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), input.editorAutoIndentEnabled === undefined ? (current.editorAutoIndentEnabled ? 1 : 0) : input.editorAutoIndentEnabled ? 1 : 0, input.editorTypewriterModeEnabled === undefined ? (current.editorTypewriterModeEnabled ? 1 : 0) : input.editorTypewriterModeEnabled ? 1 : 0, timestamp, workId);
1206
1213
  this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
1207
1214
  this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
1208
1215
  });
@@ -2106,6 +2113,7 @@ export class Store {
2106
2113
  const nextContent = input.content === undefined ? String(current.content) : input.content;
2107
2114
  const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
2108
2115
  const nextChapterType = input.chapterType ?? String(current.chapterType);
2116
+ const hasContentChange = nextContent !== current.content;
2109
2117
  const hasTextChange = nextTitle !== current.title || nextContent !== current.content;
2110
2118
  const hasTypeChange = nextChapterType !== current.chapterType;
2111
2119
  const hasOtherChange = nextExcluded !== current.excludedFromAnalysis || hasTypeChange;
@@ -2116,12 +2124,23 @@ export class Store {
2116
2124
  this.db.transaction(() => {
2117
2125
  const lockedCurrent = this.getChapter(chapterId);
2118
2126
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
2127
+ const storedLineIds = parseChapterLineIds(lockedCurrent.lineIds, String(lockedCurrent.content));
2128
+ const beforeLineIds = storedLineIds.length > 0
2129
+ ? storedLineIds
2130
+ : createChapterLineIds(String(lockedCurrent.content), () => id("chapterLine"));
2131
+ const nextLineIds = hasContentChange
2132
+ ? reconcileChapterLineIds(String(lockedCurrent.content), nextContent, beforeLineIds, input.lineIds, () => id("chapterLine"))
2133
+ : beforeLineIds;
2134
+ if (!nextLineIds)
2135
+ throw new AppError(400, "CHAPTER_LINE_IDS_INVALID", "正文行身份与当前版本不匹配,请刷新后重试");
2119
2136
  this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
2120
- excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
2137
+ excluded_from_analysis = ?, line_ids_json = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, JSON.stringify(nextLineIds), timestamp, chapterId);
2121
2138
  if (hasTextChange)
2122
2139
  this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
2123
2140
  else if (hasTypeChange)
2124
2141
  this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2142
+ if (hasContentChange)
2143
+ this.reanchorChapterAnnotations(String(current.workId), chapterId, String(lockedCurrent.content), nextContent, nextLineIds, source, timestamp);
2125
2144
  if (hasTextChange || hasTypeChange) {
2126
2145
  this.insertChapterVersionRow({
2127
2146
  workId: String(current.workId),
@@ -2145,6 +2164,36 @@ export class Store {
2145
2164
  });
2146
2165
  return this.getChapter(chapterId);
2147
2166
  }
2167
+ reanchorChapterAnnotations(workId, chapterId, beforeContent, afterContent, afterLineIds, source, timestamp) {
2168
+ const annotations = this.db.all(`SELECT id, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json
2169
+ FROM chapter_annotations
2170
+ WHERE chapter_id = ? AND deleted_at IS NULL`, chapterId).map((row) => ({
2171
+ id: requiredString(row, "id"),
2172
+ startLine: numberValue(row, "start_line"),
2173
+ endLine: numberValue(row, "end_line"),
2174
+ quote: requiredString(row, "quote"),
2175
+ lineHashes: parseChapterAnnotationLineHashes(row.line_hashes_json, requiredString(row, "quote")),
2176
+ lineIds: parseChapterAnnotationLineIds(row.anchor_line_ids_json)
2177
+ }));
2178
+ for (const annotation of reanchorChapterAnnotations(beforeContent, afterContent, annotations, afterLineIds)) {
2179
+ if (!annotation.changed)
2180
+ continue;
2181
+ this.db.run(`UPDATE chapter_annotations
2182
+ SET start_line = ?, end_line = ?, quote = ?, line_hashes_json = ?, anchor_line_ids_json = ?, version_no = version_no + 1
2183
+ WHERE id = ?`, annotation.startLine, annotation.endLine, annotation.quote, JSON.stringify(annotation.lineHashes), JSON.stringify(annotation.lineIds), annotation.id);
2184
+ const updated = this.getChapterAnnotation(annotation.id);
2185
+ this.recordChapterAnnotationVersion(updated, "reanchor", timestamp);
2186
+ this.audit(workId, "chapter.annotation.updated", "chapter-annotation", annotation.id, {
2187
+ chapterId,
2188
+ startLine: annotation.startLine,
2189
+ endLine: annotation.endLine,
2190
+ versionNo: updated.versionNo,
2191
+ anchorStrategy: annotation.anchorStrategy,
2192
+ reason: "reanchor",
2193
+ source
2194
+ });
2195
+ }
2196
+ }
2148
2197
  replaceWorkText(workId, input) {
2149
2198
  const find = input.find;
2150
2199
  if (!find)
@@ -2280,10 +2329,11 @@ export class Store {
2280
2329
  ? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId) ?? {}, "sort_order") + 1
2281
2330
  : numberValue(version, "sort_order");
2282
2331
  const timestamp = now();
2332
+ const lineIds = createChapterLineIds(content, () => id("chapterLine"));
2283
2333
  const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no") + 1;
2284
2334
  this.db.transaction(() => {
2285
- this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2286
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
2335
+ this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2336
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
2287
2337
  this.syncChapterParagraphSearch(workId, chapterId, content);
2288
2338
  this.insertChapterVersionRow({
2289
2339
  workId,
@@ -2437,11 +2487,14 @@ export class Store {
2437
2487
  };
2438
2488
  }
2439
2489
  chapterAnnotationSnapshot(annotation) {
2490
+ const anchor = this.db.get("SELECT anchor_line_ids_json FROM chapter_annotations WHERE id = ?", String(annotation.id));
2440
2491
  return {
2441
2492
  kind: annotation.kind,
2442
2493
  startLine: annotation.startLine,
2443
2494
  endLine: annotation.endLine,
2444
2495
  quote: annotation.quote,
2496
+ lineHashes: chapterAnnotationLineHashes(String(annotation.quote)),
2497
+ lineIds: parseChapterAnnotationLineIds(anchor?.anchor_line_ids_json),
2445
2498
  note: annotation.note,
2446
2499
  status: annotation.status,
2447
2500
  deletedAt: annotation.deletedAt ?? null
@@ -2555,9 +2608,15 @@ export class Store {
2555
2608
  const annotationId = id("chapterAnnotation");
2556
2609
  const timestamp = now();
2557
2610
  const actorId = currentRequestActor()?.userId ?? null;
2611
+ const quote = lines.slice(input.startLine - 1, input.endLine).join("\n");
2612
+ const chapterLineIds = parseChapterLineIds(chapter.lineIds, String(chapter.content));
2613
+ if (lines.length <= MAX_CHAPTER_LINE_IDS && chapterLineIds.length !== lines.length) {
2614
+ throw new AppError(409, "CHAPTER_LINE_IDS_MISSING", "正文行身份尚未初始化,请重新保存正文后再添加评论");
2615
+ }
2616
+ const anchorLineIds = chapterLineIds.slice(input.startLine - 1, input.endLine);
2558
2617
  this.db.transaction(() => {
2559
- this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
2560
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, lines.slice(input.startLine - 1, input.endLine).join("\n"), input.note.trim(), timestamp, timestamp, actorId, actorId);
2618
+ this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
2619
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, quote, JSON.stringify(chapterAnnotationLineHashes(quote)), JSON.stringify(anchorLineIds), input.note.trim(), timestamp, timestamp, actorId, actorId);
2561
2620
  const annotation = this.getChapterAnnotation(annotationId);
2562
2621
  this.recordChapterAnnotationVersion(annotation, "create", timestamp);
2563
2622
  this.audit(String(chapter.workId), "chapter.annotation.created", "chapter-annotation", annotationId, { kind: input.kind, chapterId, startLine: input.startLine, endLine: input.endLine });
@@ -2787,8 +2846,9 @@ export class Store {
2787
2846
  insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
2788
2847
  const chapterId = id("chapter");
2789
2848
  const timestamp = now();
2790
- this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2791
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), timestamp, timestamp);
2849
+ const lineIds = createChapterLineIds(content, () => id("chapterLine"));
2850
+ this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2851
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), timestamp, timestamp);
2792
2852
  this.syncChapterParagraphSearch(workId, chapterId, content);
2793
2853
  this.insertChapterVersionRow({
2794
2854
  workId,
@@ -3091,6 +3151,8 @@ export class Store {
3091
3151
  : optionalString(row, "cover_url"),
3092
3152
  tags: json(requiredString(row, "tags_json"), []),
3093
3153
  offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
3154
+ editorAutoIndentEnabled: numberValue(row, "editor_auto_indent_enabled") === 1,
3155
+ editorTypewriterModeEnabled: numberValue(row, "editor_typewriter_mode_enabled") === 1,
3094
3156
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
3095
3157
  ownerUserId,
3096
3158
  accessRole,
@@ -3123,7 +3185,8 @@ export class Store {
3123
3185
  mapChapter(row) {
3124
3186
  return {
3125
3187
  ...this.mapChapterDirectoryEntry(row),
3126
- content: requiredString(row, "content")
3188
+ content: requiredString(row, "content"),
3189
+ lineIds: parseChapterLineIds(row.line_ids_json, requiredString(row, "content"))
3127
3190
  };
3128
3191
  }
3129
3192
  mapChapterDirectoryEntry(row) {
@@ -5578,6 +5641,7 @@ export class Store {
5578
5641
  const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
5579
5642
  const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
5580
5643
  const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
5644
+ let roleplayMemoryMerge = { migrated: 0, deduplicated: 0 };
5581
5645
  this.db.transaction(() => {
5582
5646
  const lockedTarget = this.getCharacter(targetId);
5583
5647
  const lockedSource = this.getCharacter(sourceId);
@@ -5645,6 +5709,7 @@ export class Store {
5645
5709
  this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
5646
5710
  this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
5647
5711
  this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
5712
+ roleplayMemoryMerge = this.mergeRoleplayMemoriesForCharacters(workId, targetId, sourceId, timestamp);
5648
5713
  const sourceVersionNo = Number(source.versionNo) + 1;
5649
5714
  this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
5650
5715
  this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
@@ -5657,16 +5722,60 @@ export class Store {
5657
5722
  this.audit(workId, "character.merged", "character", targetId, {
5658
5723
  mergeId,
5659
5724
  sourceCharacterId: sourceId,
5660
- reviewId: input.reviewId
5725
+ reviewId: input.reviewId,
5726
+ roleplayMemoryMerge
5661
5727
  });
5662
5728
  });
5663
5729
  return {
5664
5730
  mergeId,
5665
5731
  target: this.getCharacter(targetId),
5666
5732
  source: this.getCharacter(sourceId),
5667
- review: input.reviewId ? this.getReviewItem(input.reviewId) : null
5733
+ review: input.reviewId ? this.getReviewItem(input.reviewId) : null,
5734
+ roleplayMemoryMerge
5668
5735
  };
5669
5736
  }
5737
+ mergeRoleplayMemoriesForCharacters(workId, targetCharacterId, sourceCharacterId, timestamp) {
5738
+ let migrated = 0;
5739
+ let deduplicated = 0;
5740
+ const sourceMemories = this.db.all("SELECT * FROM roleplay_memories WHERE work_id = ? AND character_id = ? ORDER BY created_at, id", workId, sourceCharacterId);
5741
+ for (const sourceMemory of sourceMemories) {
5742
+ const sourceMemoryId = requiredString(sourceMemory, "id");
5743
+ const duplicate = this.db.get("SELECT * FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", targetCharacterId, requiredString(sourceMemory, "content_hash"));
5744
+ if (!duplicate) {
5745
+ this.db.run("UPDATE roleplay_memories SET character_id = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?", targetCharacterId, currentRequestActor()?.userId ?? null, timestamp, sourceMemoryId);
5746
+ migrated += 1;
5747
+ continue;
5748
+ }
5749
+ const targetMemoryId = requiredString(duplicate, "id");
5750
+ this.db.run("UPDATE roleplay_memories SET superseded_by_memory_id = ? WHERE superseded_by_memory_id = ?", targetMemoryId, sourceMemoryId);
5751
+ for (const source of this.db.all("SELECT * FROM roleplay_memory_sources WHERE memory_id = ? ORDER BY created_at, id", sourceMemoryId)) {
5752
+ this.db.run(`INSERT OR IGNORE INTO roleplay_memory_sources (
5753
+ id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
5754
+ source_created_at, evidence_snapshot, created_at
5755
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), targetMemoryId, optionalString(source, "conversation_id"), optionalString(source, "message_id"), requiredString(source, "message_role"), optionalString(source, "source_created_by_user_id"), requiredString(source, "source_created_at"), requiredString(source, "evidence_snapshot"), requiredString(source, "created_at"));
5756
+ }
5757
+ let nextVersion = numberValue(duplicate, "version_no");
5758
+ for (const version of this.db.all("SELECT * FROM roleplay_memory_versions WHERE memory_id = ? ORDER BY version_no", sourceMemoryId)) {
5759
+ nextVersion += 1;
5760
+ this.db.run(`INSERT INTO roleplay_memory_versions (
5761
+ id, memory_id, version_no, category, content, importance, certainty,
5762
+ status, is_pinned, action, actor_user_id, created_at
5763
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'merged', ?, ?)`, id("roleplay_memory_version"), targetMemoryId, nextVersion, requiredString(version, "category"), requiredString(version, "content"), requiredString(version, "importance"), requiredString(version, "certainty"), requiredString(version, "status"), booleanValue(version, "is_pinned") ? 1 : 0, currentRequestActor()?.userId ?? optionalString(version, "actor_user_id"), requiredString(version, "created_at"));
5764
+ }
5765
+ nextVersion += 1;
5766
+ this.db.run(`UPDATE roleplay_memories SET is_pinned = CASE WHEN is_pinned = 1 OR ? = 1 THEN 1 ELSE 0 END,
5767
+ status = CASE WHEN status = 'active' OR ? = 'active' THEN 'active' ELSE status END,
5768
+ superseded_by_memory_id = CASE WHEN status = 'active' OR ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
5769
+ version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, booleanValue(sourceMemory, "is_pinned") ? 1 : 0, requiredString(sourceMemory, "status"), requiredString(sourceMemory, "status"), nextVersion, currentRequestActor()?.userId ?? null, timestamp, targetMemoryId);
5770
+ const mergedTarget = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", targetMemoryId);
5771
+ if (!mergedTarget)
5772
+ throw notFound("角色扮演记忆");
5773
+ this.insertRoleplayMemoryVersion(mergedTarget, "merged");
5774
+ this.db.run("DELETE FROM roleplay_memories WHERE id = ?", sourceMemoryId);
5775
+ deduplicated += 1;
5776
+ }
5777
+ return { migrated, deduplicated };
5778
+ }
5670
5779
  resolveCharacterDuplicateReview(reviewId) {
5671
5780
  const review = this.getReviewItem(reviewId);
5672
5781
  if (review.itemType !== "character-duplicate" || review.status !== "pending") {
@@ -6310,6 +6419,270 @@ export class Store {
6310
6419
  messagesPage
6311
6420
  };
6312
6421
  }
6422
+ roleplayMemoryContentHash(content) {
6423
+ return this.hashContent(normalizeRoleplayMemoryContent(content).toLocaleLowerCase("zh-CN"));
6424
+ }
6425
+ insertRoleplayMemoryVersion(memory, action) {
6426
+ this.db.run(`INSERT INTO roleplay_memory_versions (
6427
+ id, memory_id, version_no, category, content, importance, certainty,
6428
+ status, is_pinned, action, actor_user_id, created_at
6429
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_version"), requiredString(memory, "id"), numberValue(memory, "version_no"), requiredString(memory, "category"), requiredString(memory, "content"), requiredString(memory, "importance"), requiredString(memory, "certainty"), requiredString(memory, "status"), booleanValue(memory, "is_pinned") ? 1 : 0, action, currentRequestActor()?.userId ?? optionalString(memory, "updated_by_user_id"), now());
6430
+ }
6431
+ roleplayMemoryListRows(characterId, options = {}) {
6432
+ const cursor = Math.max(0, Math.min(100_000, options.cursor ?? 0));
6433
+ const limit = Math.max(1, Math.min(100, options.limit ?? 20));
6434
+ const where = ["memory.character_id = ?"];
6435
+ const params = [characterId];
6436
+ const statuses = options.statuses?.length ? [...new Set(options.statuses)] : ["active"];
6437
+ where.push(`memory.status IN (${statuses.map(() => "?").join(", ")})`);
6438
+ params.push(...statuses);
6439
+ const categories = options.categories?.length ? [...new Set(options.categories)] : [];
6440
+ if (categories.length > 0) {
6441
+ where.push(`memory.category IN (${categories.map(() => "?").join(", ")})`);
6442
+ params.push(...categories);
6443
+ }
6444
+ const query = normalizeRoleplayMemoryContent(options.query ?? "", 200).toLocaleLowerCase("zh-CN");
6445
+ if (query) {
6446
+ if (Array.from(query).length >= 3) {
6447
+ where.push("memory.id IN (SELECT memory_id FROM roleplay_memory_fts WHERE roleplay_memory_fts MATCH ?)");
6448
+ params.push(JSON.stringify(query));
6449
+ }
6450
+ else {
6451
+ where.push("lower(memory.content) LIKE ? ESCAPE '\\'");
6452
+ params.push(`%${escapeSqlLikePattern(query)}%`);
6453
+ }
6454
+ }
6455
+ const whereSql = where.join(" AND ");
6456
+ const total = Number(this.db.get(`SELECT COUNT(*) AS count FROM roleplay_memories memory WHERE ${whereSql}`, ...params)?.count ?? 0);
6457
+ const rows = this.db.all(`SELECT memory.* FROM roleplay_memories memory
6458
+ WHERE ${whereSql}
6459
+ ORDER BY memory.is_pinned DESC,
6460
+ CASE memory.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
6461
+ memory.updated_at DESC, memory.id
6462
+ LIMIT ? OFFSET ?`, ...params, limit, cursor);
6463
+ return { rows, total, cursor, limit };
6464
+ }
6465
+ listRoleplayMemories(characterId, options = {}) {
6466
+ const character = this.getCharacter(characterId);
6467
+ const page = this.roleplayMemoryListRows(characterId, options);
6468
+ const nextCursor = page.cursor + page.rows.length < page.total ? page.cursor + page.rows.length : null;
6469
+ return {
6470
+ character: { id: character.id, name: character.name, workId: character.workId },
6471
+ items: page.rows.map((memory) => this.mapRoleplayMemory(memory, true)),
6472
+ pagination: { cursor: page.cursor, limit: page.limit, total: page.total, nextCursor }
6473
+ };
6474
+ }
6475
+ isRoleplayMemorySourceTarget(sourceId, conversationId, messageId) {
6476
+ return Boolean(this.db.get(`SELECT source.id
6477
+ FROM roleplay_memory_sources source
6478
+ INNER JOIN roleplay_memories memory ON memory.id = source.memory_id
6479
+ WHERE source.id = ? AND source.conversation_id = ? AND source.message_id = ?`, sourceId, conversationId, messageId));
6480
+ }
6481
+ getRoleplayMemoryPromptItems(workId, characterId, limit = 12) {
6482
+ const character = this.getCharacter(characterId);
6483
+ if (String(character.workId) !== workId)
6484
+ throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
6485
+ return this.roleplayMemoryListRows(characterId, {
6486
+ statuses: ["active"],
6487
+ limit: Math.max(1, Math.min(30, limit))
6488
+ }).rows.map((memory) => this.mapRoleplayMemory(memory, false));
6489
+ }
6490
+ recallRoleplayMemories(workId, characterId, query, categories, cursor) {
6491
+ const character = this.getCharacter(characterId);
6492
+ if (String(character.workId) !== workId)
6493
+ throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
6494
+ const result = this.listRoleplayMemories(characterId, { query, categories, statuses: ["active"], cursor, limit: 20 });
6495
+ return {
6496
+ origin: "roleplay",
6497
+ canonical: false,
6498
+ character: result.character,
6499
+ memories: result.items,
6500
+ pagination: result.pagination
6501
+ };
6502
+ }
6503
+ createRoleplayMemory(characterId, input) {
6504
+ const character = this.getCharacter(characterId);
6505
+ if (character.mergedIntoCharacterId)
6506
+ throw new AppError(409, "ROLEPLAY_MEMORY_CHARACTER_MERGED", "已合并角色不能新增角色扮演记忆");
6507
+ const workId = String(character.workId);
6508
+ const content = normalizeRoleplayMemoryContent(input.content);
6509
+ if (!content)
6510
+ throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
6511
+ const memoryId = id("roleplay_memory");
6512
+ const contentHash = this.roleplayMemoryContentHash(content);
6513
+ const timestamp = now();
6514
+ this.db.transaction(() => {
6515
+ if (this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", characterId, contentHash)) {
6516
+ throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
6517
+ }
6518
+ this.db.run(`INSERT INTO roleplay_memories (
6519
+ id, work_id, character_id, category, content, content_hash, importance, certainty,
6520
+ status, is_pinned, version_no, source_type, created_by_user_id, updated_by_user_id, created_at, updated_at
6521
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1, 'manual', ?, ?, ?, ?)`, memoryId, workId, characterId, input.category, content, contentHash, input.importance ?? "medium", input.certainty ?? "experienced", input.isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, currentRequestActor()?.userId ?? null, timestamp, timestamp);
6522
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6523
+ if (!memory)
6524
+ throw notFound("角色扮演记忆");
6525
+ this.insertRoleplayMemoryVersion(memory, "created");
6526
+ this.audit(workId, "roleplay-memory.created", "roleplay-memory", memoryId, { characterId });
6527
+ });
6528
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6529
+ if (!memory)
6530
+ throw notFound("角色扮演记忆");
6531
+ return this.mapRoleplayMemory(memory, true);
6532
+ }
6533
+ commitRoleplayMemoryCandidates(conversationId, assistantMessageId, sourceUserMessageId, candidates) {
6534
+ if (candidates.length === 0)
6535
+ return [];
6536
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
6537
+ if (!conversation)
6538
+ throw notFound("AI 对话");
6539
+ const characterId = optionalString(conversation, "roleplay_character_id");
6540
+ if (!characterId)
6541
+ throw new AppError(409, "ROLEPLAY_MEMORY_REQUIRES_ROLEPLAY", "只有角色扮演对话可以写入角色扮演记忆");
6542
+ const workId = requiredString(conversation, "work_id");
6543
+ const assistant = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'assistant'", assistantMessageId, conversationId);
6544
+ if (!assistant)
6545
+ throw new AppError(400, "ROLEPLAY_MEMORY_ASSISTANT_MISMATCH", "角色扮演记忆来源回复不属于当前对话");
6546
+ const userMessage = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'user'", sourceUserMessageId, conversationId);
6547
+ if (!userMessage)
6548
+ throw new AppError(400, "ROLEPLAY_MEMORY_USER_MESSAGE_MISMATCH", "角色扮演记忆来源消息不属于当前对话");
6549
+ const committedIds = [];
6550
+ this.db.transaction(() => {
6551
+ const validCandidates = candidates.slice(0, 8).flatMap((candidate) => {
6552
+ const content = normalizeRoleplayMemoryContent(candidate.content, 500);
6553
+ if (!content || !roleplayMemoryCandidateIsSafe(content))
6554
+ return [];
6555
+ return [{ ...candidate, content }];
6556
+ });
6557
+ const insertable = validCandidates.flatMap((candidate) => {
6558
+ const idempotencyKey = this.hashContent(`${assistantMessageId}:${JSON.stringify(candidate)}`);
6559
+ const contentHash = this.roleplayMemoryContentHash(candidate.content);
6560
+ const existing = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND (idempotency_key = ? OR content_hash = ?)", characterId, idempotencyKey, contentHash);
6561
+ return existing ? [] : [{ candidate, idempotencyKey, contentHash }];
6562
+ });
6563
+ if (insertable.length === 0)
6564
+ return;
6565
+ const timestamp = now();
6566
+ for (const { candidate, idempotencyKey, contentHash } of insertable) {
6567
+ const memoryId = id("roleplay_memory");
6568
+ const superseded = candidate.supersedesMemoryId
6569
+ ? this.db.get("SELECT * FROM roleplay_memories WHERE id = ? AND character_id = ? AND status = 'active'", candidate.supersedesMemoryId, characterId)
6570
+ : undefined;
6571
+ this.db.run(`INSERT INTO roleplay_memories (
6572
+ id, work_id, character_id, category, content, content_hash, importance, certainty, status, is_pinned, version_no,
6573
+ source_type, source_assistant_message_id, idempotency_key,
6574
+ created_by_user_id, updated_by_user_id, created_at, updated_at
6575
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 0, 1, 'ai', ?, ?, ?, ?, ?, ?)`, memoryId, workId, characterId, candidate.category, candidate.content, contentHash, candidate.importance, candidate.certainty, assistantMessageId, idempotencyKey, optionalString(conversation, "created_by_user_id"), optionalString(conversation, "created_by_user_id"), timestamp, timestamp);
6576
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6577
+ if (!memory)
6578
+ throw notFound("角色扮演记忆");
6579
+ this.insertRoleplayMemoryVersion(memory, "created");
6580
+ for (const source of [userMessage, assistant]) {
6581
+ this.db.run(`INSERT INTO roleplay_memory_sources (
6582
+ id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
6583
+ source_created_at, evidence_snapshot, created_at
6584
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), memoryId, conversationId, requiredString(source, "id"), requiredString(source, "role"), optionalString(conversation, "created_by_user_id"), requiredString(source, "created_at"), Array.from(requiredString(source, "content")
6585
+ .replace(/\r\n?/gu, "\n")
6586
+ .replace(/\n{3,}/gu, "\n\n")
6587
+ .trim()).slice(0, 2_000).join(""), timestamp);
6588
+ }
6589
+ if (superseded) {
6590
+ const nextVersion = numberValue(superseded, "version_no") + 1;
6591
+ this.db.run(`UPDATE roleplay_memories SET status = 'superseded', superseded_by_memory_id = ?,
6592
+ version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, memoryId, nextVersion, optionalString(conversation, "created_by_user_id"), timestamp, requiredString(superseded, "id"));
6593
+ const updatedSuperseded = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", requiredString(superseded, "id"));
6594
+ if (updatedSuperseded)
6595
+ this.insertRoleplayMemoryVersion(updatedSuperseded, "superseded");
6596
+ }
6597
+ committedIds.push(memoryId);
6598
+ }
6599
+ this.audit(workId, "roleplay-memory.ai-committed", "character", characterId, {
6600
+ conversationId,
6601
+ assistantMessageId,
6602
+ memoryCount: committedIds.length
6603
+ });
6604
+ });
6605
+ return committedIds.flatMap((memoryId) => {
6606
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6607
+ return memory ? [this.mapRoleplayMemory(memory, true)] : [];
6608
+ });
6609
+ }
6610
+ getRoleplayMemoryAccess(memoryId) {
6611
+ const row = this.db.get("SELECT work_id, character_id FROM roleplay_memories WHERE id = ?", memoryId);
6612
+ if (!row)
6613
+ throw notFound("角色扮演记忆");
6614
+ return { workId: requiredString(row, "work_id"), characterId: requiredString(row, "character_id") };
6615
+ }
6616
+ updateRoleplayMemory(memoryId, input) {
6617
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6618
+ if (!memory)
6619
+ throw notFound("角色扮演记忆");
6620
+ if (numberValue(memory, "version_no") !== input.expectedVersion) {
6621
+ throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
6622
+ }
6623
+ const content = input.content === undefined ? requiredString(memory, "content") : normalizeRoleplayMemoryContent(input.content);
6624
+ if (!content)
6625
+ throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
6626
+ const category = input.category ?? requiredString(memory, "category");
6627
+ const importance = input.importance ?? requiredString(memory, "importance");
6628
+ const certainty = input.certainty ?? requiredString(memory, "certainty");
6629
+ const isPinned = input.isPinned ?? booleanValue(memory, "is_pinned");
6630
+ const contentChanged = content !== requiredString(memory, "content")
6631
+ || category !== requiredString(memory, "category")
6632
+ || importance !== requiredString(memory, "importance")
6633
+ || certainty !== requiredString(memory, "certainty");
6634
+ const pinnedChanged = isPinned !== booleanValue(memory, "is_pinned");
6635
+ if (!contentChanged && !pinnedChanged)
6636
+ return this.mapRoleplayMemory(memory, true);
6637
+ this.db.transaction(() => {
6638
+ const timestamp = now();
6639
+ const contentHash = this.roleplayMemoryContentHash(content);
6640
+ const duplicate = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ? AND id <> ?", requiredString(memory, "character_id"), contentHash, memoryId);
6641
+ if (duplicate)
6642
+ throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
6643
+ this.db.run(`UPDATE roleplay_memories SET category = ?, content = ?, content_hash = ?, importance = ?, certainty = ?, is_pinned = ?,
6644
+ version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, category, content, contentHash, importance, certainty, isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, timestamp, memoryId);
6645
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6646
+ if (!updated)
6647
+ throw notFound("角色扮演记忆");
6648
+ this.insertRoleplayMemoryVersion(updated, contentChanged ? "edited" : "pinned");
6649
+ this.audit(requiredString(memory, "work_id"), "roleplay-memory.updated", "roleplay-memory", memoryId, {
6650
+ characterId: requiredString(memory, "character_id")
6651
+ });
6652
+ });
6653
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6654
+ if (!updated)
6655
+ throw notFound("角色扮演记忆");
6656
+ return this.mapRoleplayMemory(updated, true);
6657
+ }
6658
+ setRoleplayMemoryArchived(memoryId, archived, expectedVersion) {
6659
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6660
+ if (!memory)
6661
+ throw notFound("角色扮演记忆");
6662
+ if (numberValue(memory, "version_no") !== expectedVersion) {
6663
+ throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
6664
+ }
6665
+ const nextStatus = archived ? "archived" : "active";
6666
+ if (requiredString(memory, "status") === nextStatus)
6667
+ return this.mapRoleplayMemory(memory, true);
6668
+ this.db.transaction(() => {
6669
+ const timestamp = now();
6670
+ this.db.run(`UPDATE roleplay_memories SET status = ?,
6671
+ superseded_by_memory_id = CASE WHEN ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
6672
+ version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, nextStatus, nextStatus, currentRequestActor()?.userId ?? null, timestamp, memoryId);
6673
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6674
+ if (!updated)
6675
+ throw notFound("角色扮演记忆");
6676
+ this.insertRoleplayMemoryVersion(updated, archived ? "archived" : "restored");
6677
+ this.audit(requiredString(memory, "work_id"), archived ? "roleplay-memory.archived" : "roleplay-memory.restored", "roleplay-memory", memoryId, {
6678
+ characterId: requiredString(memory, "character_id")
6679
+ });
6680
+ });
6681
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6682
+ if (!updated)
6683
+ throw notFound("角色扮演记忆");
6684
+ return this.mapRoleplayMemory(updated, true);
6685
+ }
6313
6686
  getAiConversationContext(conversationId, workId, excludeMessageId) {
6314
6687
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
6315
6688
  if (!conversation)
@@ -6335,10 +6708,13 @@ export class Store {
6335
6708
  ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
6336
6709
  }
6337
6710
  }
6711
+ const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
6338
6712
  return {
6339
6713
  workId,
6340
- roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
6714
+ taskType: (optionalString(conversation, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat")),
6715
+ roleplayCharacterId,
6341
6716
  roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
6717
+ roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
6342
6718
  summary: requiredString(conversation, "compacted_summary"),
6343
6719
  compactedMessageCount,
6344
6720
  totalMessageCount,
@@ -7043,6 +7419,49 @@ export class Store {
7043
7419
  createdAt: requiredString(row, "created_at")
7044
7420
  };
7045
7421
  }
7422
+ mapRoleplayMemory(row, includeDetails) {
7423
+ const memoryId = requiredString(row, "id");
7424
+ const actor = currentRequestActor();
7425
+ const sources = includeDetails
7426
+ ? this.db.all(`SELECT source.*, conversation.created_by_user_id AS conversation_owner_id
7427
+ FROM roleplay_memory_sources source
7428
+ LEFT JOIN ai_conversations conversation ON conversation.id = source.conversation_id
7429
+ WHERE source.memory_id = ? ORDER BY source.source_created_at, source.id`, memoryId).map((source) => {
7430
+ const sourceOwnerId = optionalString(source, "conversation_owner_id") ?? optionalString(source, "source_created_by_user_id");
7431
+ const canOpen = actor === null || actor.role === "admin" || (sourceOwnerId !== null && sourceOwnerId === actor.userId);
7432
+ return {
7433
+ id: requiredString(source, "id"),
7434
+ role: requiredString(source, "message_role"),
7435
+ sourceType: requiredString(row, "source_type"),
7436
+ sourceAt: requiredString(source, "source_created_at"),
7437
+ canOpen,
7438
+ restricted: !canOpen,
7439
+ conversationId: canOpen ? optionalString(source, "conversation_id") : null,
7440
+ messageId: canOpen ? optionalString(source, "message_id") : null,
7441
+ evidence: canOpen ? requiredString(source, "evidence_snapshot") : null
7442
+ };
7443
+ })
7444
+ : undefined;
7445
+ return {
7446
+ id: memoryId,
7447
+ workId: requiredString(row, "work_id"),
7448
+ characterId: requiredString(row, "character_id"),
7449
+ category: requiredString(row, "category"),
7450
+ content: requiredString(row, "content"),
7451
+ importance: requiredString(row, "importance"),
7452
+ certainty: requiredString(row, "certainty"),
7453
+ origin: "roleplay",
7454
+ canonical: false,
7455
+ status: requiredString(row, "status"),
7456
+ isPinned: booleanValue(row, "is_pinned"),
7457
+ versionNo: numberValue(row, "version_no"),
7458
+ supersededByMemoryId: optionalString(row, "superseded_by_memory_id"),
7459
+ sourceType: requiredString(row, "source_type"),
7460
+ ...(sources ? { sources } : {}),
7461
+ createdAt: requiredString(row, "created_at"),
7462
+ updatedAt: requiredString(row, "updated_at")
7463
+ };
7464
+ }
7046
7465
  hashContent(content) {
7047
7466
  return createHash("sha256").update(content).digest("hex");
7048
7467
  }
@@ -7750,8 +8169,20 @@ export class Store {
7750
8169
  return [];
7751
8170
  }
7752
8171
  });
7753
- summary = `提取并写入 ${events.length} 个时间轴事件候选。`;
7754
- metrics = [metric("写入事件", events.length), metric("已不存在", Math.max(0, ids.length - events.length))];
8172
+ const hasChunkMetrics = typeof result.coveredChapterCount === "number" || typeof result.batchCount === "number";
8173
+ summary = hasChunkMetrics
8174
+ ? `覆盖 ${Number(result.coveredChapterCount ?? 0)} 章正文,识别 ${Number(result.rawCandidateCount ?? events.length)} 个原始候选,写入 ${events.length} 个时间轴事件候选。`
8175
+ : `提取并写入 ${events.length} 个时间轴事件候选。`;
8176
+ metrics = [
8177
+ metric("写入事件", events.length),
8178
+ ...(hasChunkMetrics ? [
8179
+ metric("覆盖章节", result.coveredChapterCount),
8180
+ metric("正文分片", result.batchCount),
8181
+ metric("归并批次", result.aggregationBatchCount),
8182
+ metric("原始候选", result.rawCandidateCount)
8183
+ ] : []),
8184
+ metric("已不存在", Math.max(0, ids.length - events.length))
8185
+ ];
7755
8186
  storageTargets.unshift({ label: "时间轴候选", entity: "时间轴与事件", key: "timeline", count: events.length, note: "以候选状态写入,等待作者确认。" });
7756
8187
  sections = [section("事件候选", events, "没有形成可写入的时间轴事件。")];
7757
8188
  }