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 +82 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/public/favicon.png +0 -0
- package/sandbox-server.mjs +60 -66
- package/src/App.tsx +3 -6
- package/src/assets/memory-skill.json +76 -0
- package/src/assets/tavern-handbook.md +4 -0
- package/src/components/CharactersView.tsx +36 -23
- package/src/components/FileDisplayModal.tsx +90 -99
- package/src/components/SettingsView.tsx +5 -5
- package/src/components/Sidebar.tsx +14 -18
- package/src/components/WorkspaceFileManagerModal.tsx +16 -21
- package/src/core/i18n.ts +6 -3
- package/src/core/toolDefinitions.ts +22 -22
- package/src/core/tools/generatedSkillExecutor.ts +12 -4
- package/src/core/tools/toolExecutor.ts +7 -6
- package/src/db/database.ts +24 -2
- package/src/generated/changelog.json +117 -1
- package/src/store/store.ts +1 -1
- package/src/theme/chat.css +3 -0
- package/src/theme/global.css +10 -1
- package/src/theme/views.css +20 -0
- package/src/types/models.ts +3 -3
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
package/public/favicon.png
CHANGED
|
Binary file
|
package/sandbox-server.mjs
CHANGED
|
@@ -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
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
1121
|
-
if (hasExternalWrite) return 'Script write paths must stay within the current session workspace
|
|
1122
|
-
if (hasExternalPath) return 'Script paths must stay within the current session workspace
|
|
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
|
-
|
|
1171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1188
|
+
trackWrite(output);
|
|
1200
1189
|
|
|
1201
1190
|
try {
|
|
1202
|
-
for (const
|
|
1203
|
-
const
|
|
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 (
|
|
1214
|
-
child.stderr.pipe(
|
|
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
|
-
|
|
1223
|
-
|
|
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 {\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[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.
|