tavern-harness 0.1.28 → 0.1.30

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/CHANGELOG.md CHANGED
@@ -3,6 +3,86 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.30] - 2026-10-29
7
+
8
+ ### 中文
9
+
10
+ #### 新增
11
+
12
+ - 技能列表默认提供一个「记忆」技能(可编辑、可删除,也能装备给任意角色):它把玩家的称呼与偏好、人物关系、重要事件等关键信息写入本地长期记忆文件,跨会话保留;条数超限时自动遗忘最久未触发的一条,记忆发生变化时还能自动弹出记忆看板
13
+
14
+ #### 改进
15
+
16
+ - 内置世界书「酒馆经营手册(精华版)」改为只读,并在每次启动时同步为随版本更新的内置内容;编辑、重置按钮和知识库、Markdown 开关都不再可用。此前手动改过的内容会被内置版本覆盖
17
+ - 文件展示窗口会记住画中画的位置和大小,HTML 预览的缩放比例也会按文件保存,重新打开预览或重启应用后自动恢复
18
+ - 图片文件不再按文本读取并尝试预览,打开时会明确提示「不支持这种类型的文件」,不再出现乱码或损坏的图片
19
+ - 移动端的「导出游戏」按钮从「更多」菜单移到会话标题栏,与主题切换并排;桌面端按钮顺序也调整为「重置 / 删除 / 导出 / 更多」
20
+ - 侧边栏搜索会等到停止输入 250 毫秒后再执行,并改为一次性扫描消息,连续打字时列表不再卡顿
21
+ - 打开文件管理器时不再重复向本地文件服务发送探测请求,文件列表显示更快
22
+ - 站点图标换成更高分辨率的版本,在高分屏和书签栏中更清晰
23
+
24
+ #### 修复
25
+
26
+ - 删除技能后,角色身上残留的启用记录会一并清除,不会再保留一个已不存在的技能名
27
+ - 测试提供方连接后,接口地址不会再被自动改写成代理地址;模型重名检查也只比较启用中的模型,停用的模型不再误报冲突
28
+ - 沙箱脚本改为在执行前校验整条管道:重定向写错时不会再让前面的命令先执行并产生副作用;`2>` 和 `2>>` 可以用在管道的任意一段,重定向文件也会完整写完再返回
29
+
30
+ #### 内部
31
+
32
+ - 酒馆手册补充约定:HTML 技能应先初始化数据与页面文件
33
+
34
+ ### English
35
+
36
+ #### Added
37
+
38
+ - The skill list now ships with a ready-made **Memory** skill (editable, deletable, and equippable on any character). It saves key facts — how the player likes to be addressed, relationships, important events — to a local long-term memory file that persists across sessions, automatically forgets the least recently used entry when the list is full, and can pop up a memory dashboard whenever something is written
39
+
40
+ #### Changed
41
+
42
+ - The built-in **Tavern Management Handbook (Essentials)** lorebook is now read-only and is synced to the latest built-in content on every launch; the edit and reset buttons, the knowledge base, and the Markdown toggle are no longer available. Any previous manual edits to it will be overwritten by the built-in version
43
+ - The file display window now remembers its picture-in-picture position and size, and HTML preview zoom is saved per file, so both are restored after reopening the preview or restarting the app
44
+ - Image files are no longer read as text and previewed; opening one now shows a clear "This file type is not supported" message instead of garbled or broken output
45
+ - On mobile, **Export gameplay** moved out of the **More** menu into the session header next to the theme toggle; on desktop the button order is now reset / delete / export / more
46
+ - Sidebar search now waits until you stop typing for 250 ms and scans messages in a single pass, so the list no longer stutters while typing
47
+ - Opening the file manager no longer sends a duplicate probe request to the local file service, so file listings appear faster
48
+ - The site icon now uses a higher-resolution image that looks sharper on high-DPI screens and in bookmarks
49
+
50
+ #### Fixed
51
+
52
+ - Deleting a skill now also clears its stale entry from every character that had it enabled, instead of leaving a reference to a skill that no longer exists
53
+ - Testing a provider no longer rewrites the API base URL into a proxy URL; duplicate-model checks now only consider enabled models, so disabled models no longer trigger false conflicts
54
+ - Sandbox scripts now validate the whole pipeline before running: a malformed redirect can no longer let earlier commands run and cause side effects, `2>` and `2>>` work on any pipeline segment, and redirect files are fully flushed before the result is returned
55
+
56
+ #### Internal
57
+
58
+ - The tavern handbook now states that HTML skills should initialize their data and page files first
59
+
60
+ ## [0.1.29] - 2026-10-28
61
+
62
+ ### 中文
63
+
64
+ #### 改进
65
+
66
+ - 消息编辑器中编辑用户消息后,主按钮由「保存并重新生成」改为「保存并发送」,文案与实际行为一致,操作按钮的文字对齐也更整齐
67
+ - 普通会话的工作区不再包含只读的 `public` 公共目录:文件管理器和工具只能看到当前会话自己的文件,列表中所有内容都可以删除;旧会话残留的 `public` 链接会在下次使用该会话时自动清理
68
+
69
+ #### 内部
70
+
71
+ - 将内置 `file_display` 工具移到文件读写工具之后,让模型更早看到它
72
+ - 酒馆手册补充技能编写建议:HTML 技能应把数据与页面分离,更新数据时只写数据文件
73
+
74
+ ### English
75
+
76
+ #### Changed
77
+
78
+ - In the message editor, the user-message action is now labeled "Save & send" instead of "Save & regenerate", matching what it actually does; action buttons are also aligned more neatly
79
+ - Regular sessions no longer expose a read-only `public` directory in their workspace: the file manager and tools see only the session's own files, and everything listed can be deleted. Leftover `public` links are removed the next time the session is used
80
+
81
+ #### Internal
82
+
83
+ - Moved the built-in `file_display` tool right after the file read/write tools so models notice it earlier
84
+ - Added skill authoring guidance to the tavern handbook: HTML skills should separate data from presentation and only rewrite the data file on updates
85
+
6
86
  ## [0.1.28] - 2026-10-28
7
87
 
8
88
  ### 中文
@@ -800,6 +880,8 @@
800
880
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
801
881
 
802
882
  <!-- 链接定义 -->
883
+ [0.1.30]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.29...v0.1.30
884
+ [0.1.29]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.28...v0.1.29
803
885
  [0.1.28]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.27...v0.1.28
804
886
  [0.1.27]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.26...v0.1.27
805
887
  [0.1.26]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.25...v0.1.26
package/README.md CHANGED
@@ -102,7 +102,7 @@ The tavern is designed around AI assistance to make creating and playing games e
102
102
 
103
103
  Characters are more than chat partners: they are **agents with extensible skills**. Equip each character with the abilities needed for richer gameplay.
104
104
 
105
- The tavern includes essential skills such as dice rolling. You can also ask the Tavern Keeper to **create any skill you need**.
105
+ The tavern includes essential skills such as dice rolling and a default, editable **Memory** skill. Memory lets characters keep important preferences, world details, plot progress, and agreements in a local long-term-memory store. You can also ask the Tavern Keeper to **create any skill you need**.
106
106
 
107
107
  ### 💬 Multi-Character Group Chats
108
108
 
package/README.zh.md CHANGED
@@ -103,7 +103,7 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
103
103
 
104
104
  所有的角色不再是只能对话的角色,而是**具备技能拓展的智能体**:为每一个角色装备需要的技能,实现更高级的玩法。
105
105
 
106
- 酒馆已经提供了一些必要的技能(例如掷骰子);同时你也可以拜托酒馆老板,**创建任意你想要的技能**~
106
+ 酒馆已经提供了一些必要的技能(例如掷骰子和默认提供的、可编辑的**记忆**技能)。记忆技能会把重要的偏好、世界设定、剧情进展和约定保存到本地长期记忆中;同时你也可以拜托酒馆老板,**创建任意你想要的技能**~
107
107
 
108
108
  ### 💬 多角色群聊
109
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
Binary file
@@ -490,18 +490,18 @@ function resolveSessionBase(session) {
490
490
  return join(RESOLVED_WORKSPACE_ROOT, raw);
491
491
  }
492
492
 
493
- /** 创建会话目录,并为普通会话提供固定的只读公共目录入口。 */
493
+ /** 创建会话目录;普通会话不暴露公共工作区。 */
494
494
  function ensureSessionBase(base) {
495
495
  mkdirSync(PUBLIC_WORKSPACE, { recursive: true });
496
496
  mkdirSync(base, { recursive: true });
497
497
  if (base === PUBLIC_WORKSPACE) return;
498
- const publicLink = join(base, 'public');
499
- if (existsSync(publicLink)) {
500
- if (!lstatSync(publicLink).isSymbolicLink() || realpathSync(publicLink) !== realpathSync(PUBLIC_WORKSPACE)) {
501
- throw new Error('The session public entry is not a valid link to the public workspace');
502
- }
503
- } else {
504
- symlinkSync(IS_WIN ? PUBLIC_WORKSPACE : '../public', publicLink, IS_WIN ? 'junction' : 'dir');
498
+ const legacyPublicLink = join(base, 'public');
499
+ if (
500
+ existsSync(legacyPublicLink)
501
+ && lstatSync(legacyPublicLink).isSymbolicLink()
502
+ && realpathSync(legacyPublicLink) === realpathSync(PUBLIC_WORKSPACE)
503
+ ) {
504
+ rmSync(legacyPublicLink);
505
505
  }
506
506
  }
507
507
 
@@ -596,11 +596,10 @@ const server = createServer(async (req, res) => {
596
596
  const payload = JSON.parse(body);
597
597
  const base = resolveSessionBase(payload?.session);
598
598
  const rel = sanitizeWorkspaceRelativePath(payload?.path);
599
- const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
600
- if (!readingPublicLink && resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
599
+ if (resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
601
600
  const file = resolve(base, rel);
602
601
  if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
603
- assertInside(readingPublicLink ? PUBLIC_WORKSPACE : base, realpathSync(file));
602
+ assertInside(base, realpathSync(file));
604
603
  const content = await readFilePromise(file);
605
604
  if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
606
605
  res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
@@ -635,8 +634,7 @@ const server = createServer(async (req, res) => {
635
634
  const rawPath = String(payload?.path ?? '').replace(/\\/g, '/').trim();
636
635
  if (!rawPath) throw new Error('Invalid path');
637
636
  const rel = sanitizeWorkspaceRelativePath(rawPath);
638
- const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
639
- if (!readingPublicLink && resolvesOutsideSession(rel, base)) {
637
+ if (resolvesOutsideSession(rel, base)) {
640
638
  throw new Error('Path is outside the workspace');
641
639
  }
642
640
  const fp = resolve(base, rel);
@@ -649,9 +647,6 @@ const server = createServer(async (req, res) => {
649
647
  const content = String(payload?.content ?? '');
650
648
  ensureSessionBase(base);
651
649
  const rel = sanitizeWorkspaceRelativePath(rawPath);
652
- if (base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'))) {
653
- throw new Error('The public directory is read-only');
654
- }
655
650
  if (resolvesOutsideSession(rel, base)) {
656
651
  throw new Error('Path is outside the workspace');
657
652
  }
@@ -674,18 +669,14 @@ const server = createServer(async (req, res) => {
674
669
  const rawPath = String(payload?.path ?? '').replace(/\\/g, '/').trim();
675
670
  if (!rawPath) throw new Error('Invalid path');
676
671
  const rel = sanitizeWorkspaceRelativePath(rawPath);
677
- const deletingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
678
- if (!deletingPublicLink && resolvesOutsideSession(rel, base)) {
672
+ if (resolvesOutsideSession(rel, base)) {
679
673
  throw new Error('Path is outside the workspace');
680
674
  }
681
675
  const fp = resolve(base, rel);
682
676
  if (!existsSync(fp)) throw new Error('File or directory not found');
683
677
  const entry = lstatSync(fp);
684
- if (entry.isSymbolicLink() && rel === 'public') {
685
- throw new Error('The public workspace link cannot be deleted');
686
- }
687
678
  if (!entry.isFile() && !entry.isDirectory()) throw new Error('Path is not a file or directory');
688
- if (!deletingPublicLink) assertInside(base, realpathSync(fp));
679
+ assertInside(base, realpathSync(fp));
689
680
  if (entry.isDirectory()) {
690
681
  rmSync(fp, { recursive: true, force: false });
691
682
  } else {
@@ -869,14 +860,6 @@ function extractPathCandidate(value) {
869
860
  return candidate || null;
870
861
  }
871
862
 
872
- function isPublicLinkReadPath(value, sessionBase) {
873
- if (sessionBase === PUBLIC_WORKSPACE) return false;
874
- const candidate = extractPathCandidate(value);
875
- if (!candidate) return false;
876
- const normalized = candidate.replace(/\\/g, '/').trim();
877
- return normalized === 'public' || normalized.startsWith('public/');
878
- }
879
-
880
863
  /** Only the platform null device may be used outside the workspace, and only as a redirect sink. */
881
864
  function isNullDeviceSink(value) {
882
865
  const candidate = extractPathCandidate(value);
@@ -1092,34 +1075,35 @@ function validateScript(script, sessionBase) {
1092
1075
  for (const commands of groups) {
1093
1076
  commandCount += commands.length;
1094
1077
  if (commandCount > MAX_LINES) return 'Script contains more than 20 commands';
1078
+ // 管道形状错误必须在执行任何命令之前拒绝,否则前面的命令已产生副作用。
1079
+ if (commands.some((command, index) => command.input && index !== 0)) {
1080
+ return 'Input redirection is only supported for the first pipeline command';
1081
+ }
1082
+ if (commands.some((command, index) => command.output && index !== commands.length - 1)) {
1083
+ return 'Output redirection is only supported for the last pipeline command';
1084
+ }
1095
1085
  for (const { cmd, args, input, output, errorOutput } of commands) {
1096
1086
  if (!SHELL_ALLOWED_REAL.has(cmd)) hasNonAllowlistedCommand = true;
1097
1087
  const commandWriteTargets = shellWriteTargets(cmd, args);
1098
1088
  const redirectWriteTargets = [...(output ? [output.path] : []), ...(errorOutput ? [errorOutput.path] : [])];
1099
1089
  if (
1100
- commandWriteTargets.some((arg) => resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))
1101
- || redirectWriteTargets.some((arg) => (
1102
- !isNullDeviceSink(arg) && (resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))
1103
- ))
1090
+ commandWriteTargets.some((arg) => resolvesOutsideSession(arg, sessionBase))
1091
+ || redirectWriteTargets.some((arg) => !isNullDeviceSink(arg) && resolvesOutsideSession(arg, sessionBase))
1104
1092
  ) {
1105
1093
  hasExternalWrite = true;
1106
1094
  }
1107
1095
  const readTargets = [...shellReadTargets(cmd, args), ...(input ? [input] : [])];
1108
- if (readTargets.some((arg) => (
1109
- !isPublicLinkReadPath(arg, sessionBase) && resolvesOutsideSession(arg, sessionBase)
1110
- ))) {
1096
+ if (readTargets.some((arg) => resolvesOutsideSession(arg, sessionBase))) {
1111
1097
  hasExternalRead = true;
1112
1098
  }
1113
- if ([cmd, ...args].some((arg) => (
1114
- !isPublicLinkReadPath(arg, sessionBase) && resolvesOutsideSession(arg, sessionBase)
1115
- ))) {
1099
+ if ([cmd, ...args].some((arg) => resolvesOutsideSession(arg, sessionBase))) {
1116
1100
  hasExternalPath = true;
1117
1101
  }
1118
1102
  }
1119
1103
  }
1120
- if (hasExternalRead) return 'Script read paths must stay within the current session workspace; public files may only be read through the public link';
1121
- if (hasExternalWrite) return 'Script write paths must stay within the current session workspace and cannot use the public symlink';
1122
- if (hasExternalPath) return 'Script paths must stay within the current session workspace; public files may only be read through the public link';
1104
+ if (hasExternalRead) return 'Script read paths must stay within the current session workspace';
1105
+ if (hasExternalWrite) return 'Script write paths must stay within the current session workspace';
1106
+ if (hasExternalPath) return 'Script paths must stay within the current session workspace';
1123
1107
  if (hasNonAllowlistedCommand) return { needConfirmReason: 'non_allowlisted' };
1124
1108
  return null;
1125
1109
  }
@@ -1147,16 +1131,8 @@ function killChild(child) {
1147
1131
  }
1148
1132
 
1149
1133
  function runCommandGroup(group, sessionBase) {
1134
+ // 管道形状(< 只能在首段、> 只能在末段)已由 validateScript 保证。
1150
1135
  const commands = parseCommandGroup(group);
1151
- if (commands.some((command, index) => command.input && index !== 0)) {
1152
- return Promise.reject(new Error('Input redirection is only supported for the first pipeline command'));
1153
- }
1154
- if (commands.some((command, index) => command.output && index !== commands.length - 1)) {
1155
- return Promise.reject(new Error('Output redirection is only supported for the last pipeline command'));
1156
- }
1157
- if (commands.some((command, index) => command.errorOutput && index !== commands.length - 1)) {
1158
- return Promise.reject(new Error('Standard error file redirection is only supported for the last pipeline command'));
1159
- }
1160
1136
 
1161
1137
  return new Promise((resolve, reject) => {
1162
1138
  const children = [];
@@ -1167,20 +1143,21 @@ function runCommandGroup(group, sessionBase) {
1167
1143
  const output = last.output
1168
1144
  ? createWriteStream(redirectWritePath(last.output.path, sessionBase), { flags: last.output.append ? 'a' : 'w' })
1169
1145
  : null;
1170
- const errorOutput = last.errorOutput
1171
- ? createWriteStream(redirectWritePath(last.errorOutput.path, sessionBase), { flags: last.errorOutput.append ? 'a' : 'w' })
1172
- : null;
1146
+ /** 每段各自的 2> / 2>> 目标;中间段的 stderr 写入文件后不再进入后续段。 */
1147
+ const errorSinks = new Set();
1173
1148
  let stdout = '';
1174
1149
  let stderr = '';
1175
1150
  let settled = false;
1176
- let closed = 0;
1151
+ let childrenClosed = 0;
1152
+ /** 尚未写完的重定向目标(stdout 或某段的 stderr 文件)。 */
1153
+ let pendingWrites = 0;
1177
1154
  const finish = (error) => {
1178
1155
  if (settled) return;
1179
1156
  settled = true;
1180
1157
  clearTimeout(timer);
1181
1158
  input?.destroy();
1182
1159
  output?.destroy();
1183
- errorOutput?.destroy();
1160
+ for (const sink of errorSinks) sink.destroy();
1184
1161
  if (error) {
1185
1162
  children.forEach(killChild);
1186
1163
  reject(error);
@@ -1192,15 +1169,34 @@ function runCommandGroup(group, sessionBase) {
1192
1169
  });
1193
1170
  }
1194
1171
  };
1172
+ /** 所有子进程退出且重定向文件都写完后再返回,避免读到只落盘一半的结果。 */
1173
+ const resolveAfterFlush = () => {
1174
+ if (!settled && childrenClosed === commands.length && pendingWrites === 0) finish(null);
1175
+ };
1176
+ const trackWrite = (stream) => {
1177
+ if (!stream) return;
1178
+ pendingWrites += 1;
1179
+ stream.on('finish', () => {
1180
+ pendingWrites -= 1;
1181
+ resolveAfterFlush();
1182
+ });
1183
+ };
1195
1184
  const timer = setTimeout(() => finish(new Error(`Command timed out (${CMD_TIMEOUT_MS}ms)`)), CMD_TIMEOUT_MS);
1196
1185
 
1197
1186
  input?.on('error', (error) => finish(new Error(`Failed to read input: ${error.message}`)));
1198
1187
  output?.on('error', (error) => finish(new Error(`Failed to write output: ${error.message}`)));
1199
- errorOutput?.on('error', (error) => finish(new Error(`Failed to write standard error: ${error.message}`)));
1188
+ trackWrite(output);
1200
1189
 
1201
1190
  try {
1202
- for (const [index, command] of commands.entries()) {
1203
- const isLast = index === commands.length - 1;
1191
+ for (const command of commands) {
1192
+ const stderrSink = command.errorOutput
1193
+ ? createWriteStream(redirectWritePath(command.errorOutput.path, sessionBase), { flags: command.errorOutput.append ? 'a' : 'w' })
1194
+ : null;
1195
+ if (stderrSink) {
1196
+ stderrSink.on('error', (error) => finish(new Error(`Failed to write standard error: ${error.message}`)));
1197
+ errorSinks.add(stderrSink);
1198
+ trackWrite(stderrSink);
1199
+ }
1204
1200
  const child = spawn(commandPath(command.cmd), command.args, {
1205
1201
  cwd: sessionBase,
1206
1202
  shell: false,
@@ -1210,8 +1206,8 @@ function runCommandGroup(group, sessionBase) {
1210
1206
  });
1211
1207
  children.push(child);
1212
1208
  child.on('error', (error) => finish(new Error(`Failed to execute ${command.cmd}: ${error.message}`)));
1213
- if (isLast && errorOutput) {
1214
- child.stderr.pipe(errorOutput);
1209
+ if (stderrSink) {
1210
+ child.stderr.pipe(stderrSink);
1215
1211
  } else if (!command.mergeErrorToOutput) {
1216
1212
  child.stderr.on('data', (data) => {
1217
1213
  stderr += data;
@@ -1219,8 +1215,8 @@ function runCommandGroup(group, sessionBase) {
1219
1215
  });
1220
1216
  }
1221
1217
  child.on('close', () => {
1222
- closed += 1;
1223
- if (closed === children.length && children.length === commands.length && !settled) finish(null);
1218
+ childrenClosed += 1;
1219
+ resolveAfterFlush();
1224
1220
  });
1225
1221
  }
1226
1222
  for (let index = 0; index < children.length - 1; index++) {
@@ -1294,8 +1290,6 @@ async function walkWorkspace(dir, prefix, out) {
1294
1290
  try {
1295
1291
  if (ent.isDirectory()) {
1296
1292
  await walkWorkspace(full, rel, out);
1297
- } else if (ent.isSymbolicLink() && rel === 'public' && realpathSync(full) === realpathSync(PUBLIC_WORKSPACE)) {
1298
- await walkWorkspace(full, rel, out);
1299
1293
  } else if (ent.isFile()) {
1300
1294
  out.push(rel);
1301
1295
  }
package/src/App.tsx CHANGED
@@ -386,12 +386,12 @@ function SessionHeader({
386
386
  <button className="btn-ghost icon-tooltip" title={t('header.resetSessionTip')} onClick={() => setConfirmReset(true)}>
387
387
  <Icon name="refresh" size={17} />
388
388
  </button>
389
- <button className="btn-ghost icon-tooltip" title={t('header.exportGameplayTip')} onClick={() => setExportOpen(true)}>
390
- <Icon name="share" size={17} />
391
- </button>
392
389
  <button className="btn-ghost icon-tooltip danger" title={t('header.deleteTip')} onClick={() => setConfirmDelete(true)}>
393
390
  <Icon name="trash" size={17} />
394
391
  </button>
392
+ <button className="btn-ghost icon-tooltip chat-actions-export" title={t('header.exportGameplayTip')} onClick={() => setExportOpen(true)}>
393
+ <Icon name="share" size={17} />
394
+ </button>
395
395
  <button
396
396
  className="btn-ghost icon-tooltip chat-actions-more"
397
397
  type="button"
@@ -422,9 +422,6 @@ function SessionHeader({
422
422
  <button onClick={() => { setConfirmReset(true); setShowMobileActions(false); }}>
423
423
  <Icon name="refresh" size={15} /> {t('header.resetSessionTip')}
424
424
  </button>
425
- <button onClick={() => { setExportOpen(true); setShowMobileActions(false); }}>
426
- <Icon name="share" size={15} /> {t('nav.exportGameplay')}
427
- </button>
428
425
  <button className="danger" onClick={() => { setConfirmDelete(true); setShowMobileActions(false); }}>
429
426
  <Icon name="trash" size={15} /> {t('header.deleteTip')}
430
427
  </button>
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "memory",
3
+ "tool": {
4
+ "type": "function",
5
+ "function": {
6
+ "name": "memory",
7
+ "description": "跨对话长期记忆:用本地文件 memory/memories.jsonl 存储/检索/更新记忆,自带条数上限与 LRU 淘汰(最近触发的排在前面,超限时自动遗忘最久未触发的末尾记忆),并能自建 HTML 看板。记忆一旦发生写入变化(store/touch/update/forget/search命中)会自动打开看板;config.json 里 autoShow=false 可关闭。只记录关键信息(玩家称呼/偏好/人物关系/重要事件),不记琐碎细节。action=load 开场读取;store 保存;touch 提升排序;search 检索(命中即触发);list 概况;update 按 id 修改;forget 遗忘;init 初始化;show 唤出看板。",
8
+ "parameters": {
9
+ "properties": {
10
+ "action": {
11
+ "description": "操作类型。load=读取记忆(默认,开场时调用);store=保存一条新记忆(自动置顶,超限则淘汰最久未触发的);touch=手动把某条/某些记忆提到最前面(相当于一次触发);search=按关键词检索(命中即视为触发,自动置顶);list=查看概况;update=按 id 修改(同时置顶);forget=遗忘(按 id/关键词/分类);init=初始化记忆存储与看板文件;show=自建(如缺失)并打开记忆看板。",
12
+ "enum": [
13
+ "load",
14
+ "store",
15
+ "touch",
16
+ "search",
17
+ "list",
18
+ "update",
19
+ "forget",
20
+ "init",
21
+ "show"
22
+ ],
23
+ "type": "string"
24
+ },
25
+ "content": {
26
+ "description": "action=store/update 时必填。一句话概括、具体可独立理解的记忆内容。",
27
+ "type": "string"
28
+ },
29
+ "id": {
30
+ "description": "action=update 时必填的记忆 id;forget/touch 时也可用 id。",
31
+ "type": "string"
32
+ },
33
+ "ids": {
34
+ "description": "action=touch 时的记忆 id 列表(数组或逗号分隔字符串)。",
35
+ "type": "string"
36
+ },
37
+ "keyword": {
38
+ "description": "action=forget 时的关键词:记忆 id、内容关键词或分类名,匹配到的记忆会被删除。",
39
+ "type": "string"
40
+ },
41
+ "limit": {
42
+ "description": "load/search 返回条数上限,默认 load=100、search=20。",
43
+ "type": "integer"
44
+ },
45
+ "query": {
46
+ "description": "action=search 时的检索关键词(匹配内容、分类或 id);touch 时也可用 query 按内容匹配。",
47
+ "type": "string"
48
+ },
49
+ "topic": {
50
+ "description": "记忆分类,默认 general:user_preference=用户偏好(口味/喜好/称呼),world=世界设定(世界观/角色/地点),plot=剧情进展,agreement=约定与规则,general=其他。",
51
+ "enum": [
52
+ "user_preference",
53
+ "world",
54
+ "plot",
55
+ "agreement",
56
+ "general"
57
+ ],
58
+ "type": "string"
59
+ },
60
+ "touch": {
61
+ "description": "action=load 时是否把读到的记忆算作一次触发(默认 false,纯读取不改变排序)。",
62
+ "type": "boolean"
63
+ }
64
+ },
65
+ "required": [
66
+ "action"
67
+ ],
68
+ "type": "object"
69
+ }
70
+ }
71
+ },
72
+ "execution": {
73
+ "code": "const FILE = 'memory/memories.jsonl';\nconst DATA = 'memory/data.js';\nconst CONFIG = 'memory/config.json';\nconst BOARD = 'memory_board.html';\nconst MARK = '<!--MEMORY_BOARD v3-->';\nconst DEFAULT_MAX = 80;\nconst LABEL = { user_preference: '用户偏好', world: '世界设定', plot: '剧情进展', agreement: '约定', general: '其他' };\nconst ARG = (typeof input === 'object' && input) ? input : {};\nconst nowISO = () => new Date().toISOString();\nconst newId = () => 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);\n\nconst BOARD_HTML = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>记忆看板 · Memory Board</title>\n<!--MEMORY_BOARD v3-->\n<style>\n:root{--bg:#0b0a08;--p:#15120e;--p2:#1d1812;--ln:#3a2f22;--am:#e8b465;--dm:#a8843f;--tx:#efe6d8;--mu:#9a8b76;--rd:#d9756b}\n*{box-sizing:border-box}html,body{margin:0;min-height:100%}\nbody{background:radial-gradient(1200px 600px at 50% -10%,#241b10,transparent 60%),radial-gradient(900px 500px at 90% 110%,#1a1610,transparent 55%),var(--bg);color:var(--tx);font-family:\"PingFang SC\",\"Microsoft YaHei\",-apple-system,system-ui,sans-serif;padding:26px 18px 60px}\n.w{max-width:940px;margin:0 auto}\nheader{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;padding-bottom:14px;border-bottom:1px solid var(--ln);margin-bottom:20px;flex-wrap:wrap}\nh1{margin:0;font-size:1.42rem;letter-spacing:.16em;font-weight:600;color:var(--am)}\nh1 small{display:block;font-size:.6rem;letter-spacing:.3em;color:var(--mu);margin-top:5px;font-weight:400}\n.st{font-size:.7rem;color:var(--mu);text-align:right;line-height:1.7;font-variant-numeric:tabular-nums}\n.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:10px;margin-bottom:18px}\n.sc{background:linear-gradient(160deg,var(--p2),var(--p));border:1px solid var(--ln);border-radius:11px;padding:13px 15px}\n.sc .n{font-size:1.45rem;font-weight:700;color:var(--am);font-variant-numeric:tabular-nums;line-height:1.1}\n.sc .n small{font-size:.78rem;color:var(--mu);font-weight:400}\n.sc .l{font-size:.7rem;color:var(--mu);margin-top:5px;letter-spacing:.06em}\n.bar{height:5px;border-radius:9px;background:#241c14;margin-top:9px;overflow:hidden}\n.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--dm),var(--am));border-radius:9px;transition:width .4s}\ninput[type=search]{width:100%;background:var(--p);border:1px solid var(--ln);color:var(--tx);border-radius:9px;padding:10px 13px;font-size:.85rem;outline:none;margin-bottom:12px}\ninput[type=search]:focus{border-color:var(--dm)}input[type=search]::placeholder{color:#6d6152}\n.chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:16px}\n.chip{background:var(--p);border:1px solid var(--ln);color:var(--mu);border-radius:99px;padding:6px 13px;font-size:.76rem;cursor:pointer;user-select:none;white-space:nowrap;transition:.18s}\n.chip:hover{border-color:var(--dm);color:var(--tx)}\n.chip.on{background:rgba(232,180,101,.14);border-color:var(--dm);color:var(--am)}\n.list{display:flex;flex-direction:column;gap:9px}\n.card{background:linear-gradient(160deg,var(--p2),var(--p));border:1px solid var(--ln);border-left:3px solid var(--dm);border-radius:11px;padding:12px 15px;display:flex;gap:11px;animation:in .3s ease both;transition:.16s}\n.card:hover{transform:translateX(2px);border-color:#4a3b28}\n.card.risk{border-left-color:var(--rd)}\n@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}\n.rk{font-family:\"SF Mono\",Consolas,monospace;font-size:.68rem;color:#6d6152;min-width:20px;padding-top:3px;text-align:right}\n.bd{flex:1;min-width:0}\n.top{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-bottom:6px}\n.badge{font-size:.64rem;padding:2px 8px;border-radius:99px;border:1px solid}\n.t-user_preference{color:#7dc98a;border-color:rgba(125,201,138,.35);background:rgba(125,201,138,.1)}\n.t-world{color:#7fa8d9;border-color:rgba(127,168,217,.35);background:rgba(127,168,217,.1)}\n.t-plot{color:var(--am);border-color:rgba(232,180,101,.35);background:rgba(232,180,101,.1)}\n.t-agreement{color:#b394d9;border-color:rgba(179,148,217,.35);background:rgba(179,148,217,.1)}\n.t-general{color:var(--mu);border-color:var(--ln);background:rgba(154,139,118,.08)}\n.rt{font-size:.6rem;padding:2px 7px;border-radius:99px;color:var(--rd);border:1px solid rgba(217,117,107,.4);background:rgba(217,117,107,.1)}\n.mt{font-size:.66rem;color:var(--mu);font-variant-numeric:tabular-nums}\n.id{margin-left:auto;font-size:.62rem;color:#6d6152;font-family:\"SF Mono\",Consolas,monospace;cursor:pointer}\n.id:hover{color:var(--am)}\n.ct{font-size:.88rem;line-height:1.68;white-space:pre-wrap;word-break:break-word}\nmark{background:rgba(232,180,101,.3);color:#fff2d8;border-radius:3px}\n.empty{text-align:center;padding:56px 18px;color:var(--mu);border:1px dashed var(--ln);border-radius:13px;font-size:.88rem;line-height:2}\n.empty .big{font-size:1.9rem;display:block;margin-bottom:9px;opacity:.6}\nfooter{margin-top:22px;text-align:center;font-size:.66rem;color:#5d5346;line-height:1.9}\n.toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,18px);background:var(--am);color:#20170c;font-size:.76rem;font-weight:600;padding:8px 16px;border-radius:99px;opacity:0;pointer-events:none;transition:.25s}\n.toast.on{opacity:1;transform:translate(-50%,0)}\n</style>\n</head>\n<body>\n<div class=\"w\">\n<header><h1>记忆看板<small>MEMORY BOARD</small></h1><div class=\"st\" id=\"stamp\">—</div></header>\n<div class=\"stats\">\n<div class=\"sc\"><div class=\"n\" id=\"sTotal\">0<small id=\"sMax\"> / 0</small></div><div class=\"l\">记忆条数 / 上限</div><div class=\"bar\"><i id=\"sBar\" style=\"width:0\"></i></div></div>\n<div class=\"sc\"><div class=\"n\" id=\"sTopic\">0</div><div class=\"l\">涉及分类</div></div>\n<div class=\"sc\"><div class=\"n\" id=\"sLast\">—</div><div class=\"l\">最近触发</div></div>\n</div>\n<input type=\"search\" id=\"q\" placeholder=\"检索记忆内容或分类…\" autocomplete=\"off\">\n<div class=\"chips\" id=\"chips\"></div>\n<div class=\"list\" id=\"list\"></div>\n<footer>按最近触发时间排序 · 上方最新,下方最旧<br>达到上限后,新记忆会把最下方最久未触发的记忆挤出去</footer>\n</div>\n<div class=\"toast\" id=\"toast\">已复制</div>\n<script src=\"memory/data.js\"></script>\n<script>\n(function(){\nvar L={user_preference:\"用户偏好\",world:\"世界设定\",plot:\"剧情进展\",agreement:\"约定\",general:\"其他\"};\nvar d=(window.MEMORY_DATA&&Array.isArray(window.MEMORY_DATA.memories))?window.MEMORY_DATA:{memories:[],updatedAt:null,max:0};\nvar S={topic:\"all\",q:\"\"};\nfunction $(i){return document.getElementById(i)}\nfunction lb(t){return L[t]||t||\"其他\"}\nfunction esc(s){return String(s).replace(/[&<>\"']/g,function(c){return {\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\",\"'\":\"&#39;\"}[c]})}\nfunction hl(s,q){\n var safe=esc(s);if(!q)return safe;\n var lq=q.toLowerCase(),ls=safe.toLowerCase(),o=\"\",i=0,j;\n while((j=ls.indexOf(lq,i))>=0){o+=safe.slice(i,j)+\"<mark>\"+safe.slice(j,j+lq.length)+\"</mark>\";i=j+lq.length}\n return o+safe.slice(i)\n}\nfunction ft(iso){\n if(!iso)return \"—\";var x=new Date(iso);if(isNaN(x.getTime()))return \"—\";\n function p(n){return String(n).padStart(2,\"0\")}\n return x.getFullYear()+\"-\"+p(x.getMonth()+1)+\"-\"+p(x.getDate())+\" \"+p(x.getHours())+\":\"+p(x.getMinutes())\n}\nfunction ago(iso){\n if(!iso)return \"\";var t=new Date(iso).getTime();if(isNaN(t))return \"\";\n var s=Math.floor((Date.now()-t)/1000);\n if(s<60)return \"刚刚\";\n if(s<3600)return Math.floor(s/60)+\" 分钟前\";\n if(s<86400)return Math.floor(s/3600)+\" 小时前\";\n return Math.floor(s/86400)+\" 天前\"\n}\nfunction tps(){var seen={},out=[];d.memories.forEach(function(m){var t=m.topic||\"general\";if(!seen[t]){seen[t]=1;out.push(t)}});return out}\nfunction stats(){\n var n=d.memories.length,mx=d.max||0;\n $(\"sTotal\").innerHTML=n+'<small id=\"sMax\"> / '+mx+\"</small>\";\n $(\"sTopic\").textContent=tps().length;\n var pct=mx?Math.min(100,Math.round(n/mx*100)):0,b=$(\"sBar\");\n b.style.width=pct+\"%\";\n b.style.background=pct>=90?\"linear-gradient(90deg,#a8553f,#d9756b)\":\"linear-gradient(90deg,#a8843f,#e8b465)\";\n $(\"sLast\").textContent=n?ago(d.memories[0].used):\"—\";\n $(\"stamp\").innerHTML=\"同步时间 \"+ft(d.updatedAt)+\"<br>\"+n+\" / \"+mx+\" 条 · 按触发时间排序\"\n}\nfunction chips(){\n var box=$(\"chips\"),all=[{k:\"all\",n:\"全部 (\"+d.memories.length+\")\"}];\n tps().forEach(function(t){\n var c=d.memories.filter(function(m){return (m.topic||\"general\")===t}).length;\n all.push({k:t,n:lb(t)+\" (\"+c+\")\"})\n });\n box.innerHTML=all.map(function(x){\n return '<div class=\"chip'+(S.topic===x.k?\" on\":\"\")+'\" data-topic=\"'+esc(x.k)+'\">'+esc(x.n)+\"</div>\"\n }).join(\"\");\n Array.prototype.forEach.call(box.children,function(el){\n el.onclick=function(){S.topic=el.getAttribute(\"data-topic\");chips();list()}\n })\n}\nfunction list(){\n var q=S.q.trim().toLowerCase(),mx=d.max||0,n=d.memories.length;\n var from=Math.max(0,n-5),near=mx>0&&n>=mx*0.8;\n var items=d.memories.filter(function(m){\n if(S.topic!==\"all\"&&(m.topic||\"general\")!==S.topic)return false;\n if(!q)return true;\n return (String(m.content||\"\")+\" \"+lb(m.topic)+\" \"+String(m.id||\"\")).toLowerCase().indexOf(q)>=0\n });\n var box=$(\"list\");\n if(!items.length){\n box.innerHTML='<div class=\"empty\"><span class=\"big\">✦</span>'+(n?\"没有匹配的记忆\":\"还没有任何记忆<br>对话中一旦有了值得记住的事,它就会出现在这里\")+\"</div>\";\n return\n }\n box.innerHTML=items.map(function(m,i){\n var t=m.topic||\"general\",idx=d.memories.indexOf(m),rk=idx+1;\n var risk=near&&idx>=from;\n return '<div class=\"card'+(risk?\" risk\":\"\")+'\" style=\"animation-delay:'+Math.min(i*22,260)+'ms\">'+\n '<div class=\"rk\">'+rk+'</div><div class=\"bd\"><div class=\"top\">'+\n '<span class=\"badge t-'+esc(t)+'\">'+esc(lb(t))+\"</span>\"+\n (risk?'<span class=\"rt\">最久未触发</span>':\"\")+\n '<span class=\"mt\">触发 '+esc(ago(m.used))+\"</span>\"+\n '<span class=\"id\" data-id=\"'+esc(m.id||\"\")+'\" title=\"点击复制 ID\">'+esc(m.id||\"\")+\"</span></div>\"+\n '<div class=\"ct\">'+hl(m.content||\"\",S.q.trim())+\"</div></div></div>\"\n }).join(\"\");\n Array.prototype.forEach.call(box.querySelectorAll(\".id\"),function(el){\n el.onclick=function(){\n var id=el.getAttribute(\"data-id\");if(!id)return;\n function done(){var t=$(\"toast\");t.classList.add(\"on\");setTimeout(function(){t.classList.remove(\"on\")},1200)}\n if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(id).then(done,done)}else{done()}\n }\n })\n}\n$(\"q\").addEventListener(\"input\",function(e){S.q=e.target.value;list()});\nstats();chips();list()\n})();\n</script>\n</body>\n</html>`;\n\nasync function rd(p) { try { const t = await $read(p); return t == null ? '' : String(t); } catch (e) { return null; } }\n\nasync function getCfg() {\n const raw = await rd(CONFIG);\n let max = DEFAULT_MAX, autoShow = true;\n if (raw) {\n try {\n const o = JSON.parse(raw);\n const n = Number(o.max);\n if (isFinite(n) && n >= 5 && n <= 1000) max = Math.floor(n);\n if (o.autoShow === false) autoShow = false;\n } catch (e) {}\n }\n return { max, autoShow };\n}\n\nfunction sortByUsed(list) {\n list.sort((a, b) => {\n const ua = a.used || a.time || '', ub = b.used || b.time || '';\n if (ua === ub) return String(a.id).localeCompare(String(b.id));\n return ua < ub ? 1 : -1;\n });\n return list;\n}\n\nasync function loadAll() {\n const raw = await rd(FILE);\n if (!raw) return [];\n const out = [];\n raw.split('\\n').forEach(line => {\n line = line.trim();\n if (!line) return;\n try {\n const o = JSON.parse(line);\n if (o && o.content) {\n if (!o.used) o.used = o.time || nowISO();\n out.push(o);\n }\n } catch (e) {}\n });\n return sortByUsed(out);\n}\n\nasync function sync(list, max) {\n await $write(DATA, 'window.MEMORY_DATA = ' + JSON.stringify({ updatedAt: nowISO(), count: list.length, max: max, memories: list }) + ';\\n');\n}\n\n// 排序后从末尾淘汰超限记忆:末尾 = 最久未触发\nasync function save(list) {\n sortByUsed(list);\n const { max } = await getCfg();\n const evicted = [];\n while (list.length > max) evicted.push(list.pop());\n await $write(FILE, list.map(m => JSON.stringify(m)).join('\\n') + (list.length ? '\\n' : ''));\n await sync(list, max);\n return evicted;\n}\n\nasync function ensureBoard(list) {\n const cur = await rd(BOARD);\n let wrote = false;\n if (cur == null || cur.indexOf(MARK) < 0) { await $write(BOARD, BOARD_HTML + '\\n'); wrote = true; }\n if (!(await rd(DATA))) { await sync(list || [], (await getCfg()).max); wrote = true; }\n if (!(await rd(CONFIG))) { await $write(CONFIG, JSON.stringify({ max: DEFAULT_MAX, autoShow: true }) + '\\n'); wrote = true; }\n return wrote;\n}\n\nconst fmt = m => '· [' + (m.id || '?') + '] (' + (LABEL[m.topic] || m.topic || '其他') + ') ' + m.content + (m.used ? ' @' + String(m.used).slice(0, 16).replace('T', ' ') : '');\nconst evt = a => a.length ? '\\n因超出上限淘汰最久未触发的 ' + a.length + ' 条:\\n' + a.map(fmt).join('\\n') : '';\n\nlet MEM_OUT;\ntry {\n const action = String(ARG.action || 'load');\n let list = await loadAll();\n const cfg = await getCfg();\n const max = cfg.max;\n let WROTE = false;\n\n if (action === 'load') {\n const lim = Math.max(1, Number(ARG.limit) || 100);\n const top = list.slice(0, lim);\n let touched = 0;\n // load 默认只读、不改排序;传 touch=true 才把读到的算作触发\n if (ARG.touch === true) {\n const t = Date.now();\n top.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n touched = top.length;\n await save(list);\n WROTE = true;\n }\n MEM_OUT = {\n ok: true, action, count: list.length, max, status: list.length ? 'ok' : 'empty', touched, memories: top,\n text: list.length\n ? '记忆 ' + list.length + '/' + max + ' 条,以下为最近触发的 ' + top.length + ' 条(新→旧,末尾最易被淘汰):\\n' + top.map(fmt).join('\\n')\n : '(记忆为空,还没有任何记录)'\n };\n } else if (action === 'store') {\n const content = String(ARG.content || '').trim();\n if (!content) throw new Error('store 需要 content');\n const dup = list.find(m => String(m.content || '').trim() === content);\n if (dup) {\n dup.used = nowISO();\n const ev = await save(list);\n MEM_OUT = { ok: true, action, duplicate: true, id: dup.id, count: list.length, max, evicted: ev, text: '已有相同记忆 [' + dup.id + '],仅刷新触发时间,未重复写入。' + evt(ev) };\n } else {\n const t = String(ARG.topic || 'general');\n const topic = LABEL[t] ? t : 'general';\n const m = { id: newId(), topic, content, time: nowISO(), used: nowISO() };\n list.unshift(m);\n const ev = await save(list);\n MEM_OUT = {\n ok: true, action, id: m.id, topic, count: list.length, max, evicted: ev,\n text: '已记住 [' + m.id + '](' + LABEL[topic] + '):' + content + '\\n当前 ' + list.length + '/' + max + ' 条' + evt(ev)\n };\n }\n WROTE = true;\n } else if (action === 'touch') {\n const raw = ARG.ids || ARG.id || ARG.query || '';\n const keys = Array.isArray(raw) ? raw.map(String) : String(raw).split(',').map(s => s.trim()).filter(Boolean);\n if (!keys.length) throw new Error('touch 需要 ids(id,可逗号分隔)或 query');\n const hit = list.filter(m => keys.some(k => m.id === k || String(m.content || '').toLowerCase().includes(k.toLowerCase())));\n if (!hit.length) {\n MEM_OUT = { ok: true, action, touched: [], count: list.length, max, text: '没有找到要触发的记忆,未做改动。' };\n } else {\n const t = Date.now();\n hit.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n const ev = await save(list);\n MEM_OUT = { ok: true, action, touched: hit, count: list.length, max, evicted: ev, text: '已把 ' + hit.length + ' 条记忆提到最前面:\\n' + hit.map(fmt).join('\\n') + evt(ev) };\n WROTE = true;\n }\n } else if (action === 'update') {\n const id = String(ARG.id || '').trim();\n const i = list.findIndex(m => m.id === id);\n if (i < 0) throw new Error('找不到记忆 id:' + id);\n const content = String(ARG.content || '').trim();\n if (content) list[i].content = content;\n if (ARG.topic && LABEL[ARG.topic]) list[i].topic = ARG.topic;\n list[i].updated = nowISO();\n list[i].used = nowISO();\n const ev = await save(list);\n MEM_OUT = { ok: true, action, id, count: list.length, max, memory: list[i], evicted: ev, text: '已更新 [' + id + '](并提到最前面):' + list[i].content + evt(ev) };\n WROTE = true;\n } else if (action === 'forget') {\n const key = String(ARG.keyword || ARG.id || '').trim();\n if (!key) throw new Error('forget 需要 keyword(记忆 id 或关键词)');\n const low = key.toLowerCase();\n const del = list.filter(m => m.id === key || String(m.content || '').toLowerCase().includes(low) || m.topic === key || (LABEL[m.topic] || '') === key);\n if (!del.length) {\n MEM_OUT = { ok: true, action, removed: [], count: list.length, max, text: '没有找到匹配「' + key + '」的记忆,未做改动。' };\n } else {\n const ids = new Set(del.map(m => m.id));\n list = list.filter(m => !ids.has(m.id));\n await save(list);\n MEM_OUT = { ok: true, action, removed: del, count: list.length, max, text: '已遗忘 ' + del.length + ' 条记忆:\\n' + del.map(fmt).join('\\n') };\n WROTE = true;\n }\n } else if (action === 'search') {\n const q = String(ARG.query || '').trim();\n if (!q) throw new Error('search 需要 query');\n const low = q.toLowerCase();\n const lim = Math.max(1, Number(ARG.limit) || 20);\n const hit = list.filter(m => String(m.content || '').toLowerCase().includes(low) || String(m.id || '').toLowerCase().includes(low) || m.topic === q || (LABEL[m.topic] || '').includes(q)).slice(0, lim);\n // 检索命中即视为触发:提到最前,避免常用记忆被淘汰\n if (hit.length && ARG.touch !== false) {\n const t = Date.now();\n hit.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n await save(list);\n WROTE = true;\n }\n MEM_OUT = {\n ok: true, action, query: q, count: hit.length, max, memories: hit,\n text: hit.length ? '找到 ' + hit.length + ' 条与「' + q + '」相关的记忆(已提到最前面):\\n' + hit.map(fmt).join('\\n') : '没有找到与「' + q + '」相关的记忆。'\n };\n } else if (action === 'list') {\n const byTopic = {};\n list.forEach(m => { const t = m.topic || 'general'; byTopic[t] = (byTopic[t] || 0) + 1; });\n const rows = Object.keys(byTopic).map(t => (LABEL[t] || t) + ':' + byTopic[t] + ' 条');\n MEM_OUT = {\n ok: true, action, count: list.length, max, byTopic, status: list.length ? 'ok' : 'empty',\n text: list.length\n ? '记忆概况:' + list.length + '/' + max + ' 条(' + rows.join(',') + ')\\n最近触发:' + String(list[0].used || '').slice(0, 16).replace('T', ' ') + '\\n最久未触发(下次写入时最先被淘汰):\\n' + list.slice(-3).reverse().map(fmt).join('\\n')\n : '记忆概况:暂无任何记忆(上限 ' + max + ' 条)'\n };\n } else if (action === 'init') {\n const wrote = await ensureBoard(list);\n await $write(CONFIG, JSON.stringify({ max: max, autoShow: cfg.autoShow }) + '\\n');\n const ev = await save(list);\n MEM_OUT = { ok: true, action, count: list.length, max, board: BOARD, data: DATA, config: CONFIG, rebuilt: wrote, evicted: ev, text: '已初始化记忆存储与看板:' + BOARD + (wrote ? '(本次重建)' : '(已是最新)') + ',上限 ' + max + ' 条。' };\n WROTE = ev.length > 0;\n } else if (action === 'show') {\n const rebuilt = await ensureBoard(list);\n await save(list);\n if (!(await rd(BOARD))) throw new Error('看板文件 ' + BOARD + ' 无法创建');\n await $display(BOARD, { title: '记忆看板' });\n MEM_OUT = { ok: true, action, count: list.length, max, rebuilt, text: '已打开记忆看板(' + list.length + '/' + max + ' 条)' + (rebuilt ? ',看板文件本次已自动初始化。' : '。') };\n } else {\n throw new Error('未知 action:' + action + '(可用 load / store / touch / update / search / list / forget / init / show)');\n }\n\n // 记忆有变化时主动打开看板;config.autoShow=false 可关闭\n if (WROTE && MEM_OUT.ok && cfg.autoShow) {\n await ensureBoard(list);\n await $display(BOARD, { title: '记忆看板' });\n MEM_OUT.shown = true;\n MEM_OUT.text += '\\n(记忆有更新,已打开看板)';\n }\n} catch (e) {\n MEM_OUT = { ok: false, error: String((e && e.message) || e) };\n}\nresult = MEM_OUT;\n",
74
+ "type": "javascript"
75
+ }
76
+ }
@@ -37,3 +37,7 @@
37
37
  1. **Create the minimum number of skills**: Assess responsibilities against the user's needs. If one skill can accomplish the goal, do not split it into or add multiple redundant skills.
38
38
  2. **Document skill usage and workflows clearly**: In both World Info and the character persona, clearly record each skill's trigger conditions and usage steps so the character can invoke it as required.
39
39
  3. **Verify character skill equipment**: After creating or configuring a game, confirm that each relevant character has every skill required by the workflow. In particular, verify that relevant built-in skills are enabled.
40
+
41
+ ### Skill Creation
42
+ 1. **Separate data from presentation in HTML-based skills**: HTML is generally generated once as a static page. When data needs to be updated, the skill should write only the data file, while the page reads that data at runtime.
43
+ 2. **Initialize HTML files in HTML-based skills**: Character working directories are isolated and cannot access each other's files. Each HTML-based skill must be able to create and initialize the HTML files it requires in its own working directory.