dsh-world 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +161 -161
- package/INSTALL.md +314 -311
- package/README.md +18 -13
- package/client/index.js +20 -1
- package/lib/compactionSwap.d.ts +29 -0
- package/lib/compactionSwap.js +56 -0
- package/lib/console/routes.d.ts +2 -0
- package/lib/console/routes.js +6 -0
- package/lib/console/snapshot.d.ts +1 -0
- package/lib/console/snapshot.js +1 -0
- package/lib/ecosystem/qq-native/home.js +2 -2
- package/lib/ecosystem/shared/context.d.ts +2 -1
- package/lib/ecosystem/shared/context.js +8 -1
- package/lib/ecosystem/wechat/home.js +4 -4
- package/lib/ecosystem/wechat/index.js +13 -4
- package/lib/ecosystem/wechat/messages.js +1 -1
- package/lib/ecosystem/wechat/reading.d.ts +9 -0
- package/lib/ecosystem/wechat/reading.js +61 -12
- package/lib/ecosystem/wechat/sending.d.ts +1 -0
- package/lib/ecosystem/wechat/sending.js +3 -0
- package/lib/ecosystem/wechat/stickerLibrary.d.ts +50 -0
- package/lib/ecosystem/wechat/stickerLibrary.js +167 -0
- package/lib/ecosystem/wechat/stickerTools.d.ts +18 -0
- package/lib/ecosystem/wechat/stickerTools.js +69 -0
- package/lib/index.d.ts +3 -1
- package/lib/index.js +9 -2
- package/lib/life/activity.js +5 -0
- package/lib/life/runtime.d.ts +2 -0
- package/lib/life/runtime.js +4 -2
- package/lib/runtime.js +36 -37
- package/lib/types.d.ts +2 -0
- package/lib/worldSwitch.d.ts +7 -1
- package/lib/worldSwitch.js +12 -1
- package/package.json +115 -114
- package/scripts/import-stickers.mjs +17 -0
- package/src/ecosystem/wechat/windows/README.md +22 -5
- package/src/ecosystem/wechat/windows/adapter.py +24 -5
- package/src/ecosystem/wechat/windows/groups.py +2 -2
- package/src/ecosystem/wechat/windows/native.py +4 -3
- package/src/ecosystem/wechat/windows/outgoing.py +14 -7
- package/src/ecosystem/wechat/windows/reading.py +58 -1
- package/src/ecosystem/wechat/windows/snapshot.py +8 -0
- package/src/ecosystem/wechat/windows/stickers.py +69 -60
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type StickerMediaType = 'image/gif' | 'image/png' | 'image/webp' | 'image/jpeg';
|
|
2
|
+
/** `file` lives under `files/` beside the index; the adapter reads both when it sends. */
|
|
3
|
+
export interface Sticker {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
tags: string[];
|
|
7
|
+
file: string;
|
|
8
|
+
mediaType: StickerMediaType;
|
|
9
|
+
md5: string;
|
|
10
|
+
bytes: number;
|
|
11
|
+
addedAt: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StickerLabels {
|
|
14
|
+
name?: string;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare const MAX_STICKERS = 500;
|
|
18
|
+
export declare const MAX_STICKER_BYTES: number;
|
|
19
|
+
/** The world's own sticker library: a folder with `index.json` and the image files, written atomically and one change at a time. */
|
|
20
|
+
export declare function createStickerLibrary(dir: string): {
|
|
21
|
+
dir: string;
|
|
22
|
+
list: () => Promise<Sticker[]>;
|
|
23
|
+
has(id: string): Promise<boolean>;
|
|
24
|
+
/** `existing`: the same image was already in the library, so nothing changed. `id` is only for importing an older library. */
|
|
25
|
+
add: (data: Buffer, labels: StickerLabels & {
|
|
26
|
+
id?: string;
|
|
27
|
+
}, options?: {
|
|
28
|
+
expectedMd5?: string;
|
|
29
|
+
}) => Promise<{
|
|
30
|
+
sticker: Sticker;
|
|
31
|
+
existing: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
edit: (id: string, labels: StickerLabels) => Promise<Sticker>;
|
|
34
|
+
remove: (id: string) => Promise<void>;
|
|
35
|
+
/** A few best matches, newest first among equals; a query matches the name and tags, any word counting once. */
|
|
36
|
+
search(input: {
|
|
37
|
+
query?: string;
|
|
38
|
+
limit?: number;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
total: number;
|
|
41
|
+
items: Sticker[];
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
export type StickerLibrary = ReturnType<typeof createStickerLibrary>;
|
|
45
|
+
/** One-time import of the older sticker plugin's folder (`catalog.json` + `assets/`): ids, names and tags carry over, packs and long meanings do not. */
|
|
46
|
+
export declare function importLegacyStickers(legacyDir: string, library: StickerLibrary): Promise<{
|
|
47
|
+
imported: number;
|
|
48
|
+
duplicates: number;
|
|
49
|
+
skipped: string[];
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
export const MAX_STICKERS = 500;
|
|
5
|
+
export const MAX_STICKER_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const EXTENSION = { 'image/gif': 'gif', 'image/png': 'png', 'image/webp': 'webp', 'image/jpeg': 'jpg' };
|
|
7
|
+
const idPattern = /^st_[A-Za-z0-9_-]{4,40}$/;
|
|
8
|
+
const control = /[\u0000-\u001f\u007f]/;
|
|
9
|
+
const length = (text) => [...text].length;
|
|
10
|
+
function mediaTypeOf(data) {
|
|
11
|
+
if (data.length > 12) {
|
|
12
|
+
if (data.subarray(0, 6).toString('latin1') === 'GIF87a' || data.subarray(0, 6).toString('latin1') === 'GIF89a')
|
|
13
|
+
return 'image/gif';
|
|
14
|
+
if (data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
15
|
+
return 'image/png';
|
|
16
|
+
if (data.subarray(0, 4).toString('latin1') === 'RIFF' && data.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
17
|
+
return 'image/webp';
|
|
18
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
19
|
+
return 'image/jpeg';
|
|
20
|
+
}
|
|
21
|
+
throw new Error('表情包需要是 GIF、PNG、WebP 或 JPEG 图片');
|
|
22
|
+
}
|
|
23
|
+
function cleanName(value) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (typeof value !== 'string' || !value.trim() || length(value.trim()) > 30 || control.test(value))
|
|
27
|
+
throw new Error('名称最长 30 字,不能含控制字符');
|
|
28
|
+
return value.trim();
|
|
29
|
+
}
|
|
30
|
+
function cleanTags(value) {
|
|
31
|
+
const tags = Array.isArray(value) ? value : [];
|
|
32
|
+
const clean = [...new Set(tags.map(tag => typeof tag === 'string' ? tag.trim() : tag))];
|
|
33
|
+
if (!clean.length || clean.length > 8 || clean.some(tag => typeof tag !== 'string' || !tag || length(tag) > 16 || control.test(tag)))
|
|
34
|
+
throw new Error('标签需要 1–8 个,每个 1–16 字');
|
|
35
|
+
return clean;
|
|
36
|
+
}
|
|
37
|
+
function parseIndex(text) {
|
|
38
|
+
const value = JSON.parse(text);
|
|
39
|
+
if (value.version !== 1 || !Array.isArray(value.stickers))
|
|
40
|
+
throw new Error('结构不正确');
|
|
41
|
+
for (const entry of value.stickers) {
|
|
42
|
+
if (!entry || typeof entry.id !== 'string' || !idPattern.test(entry.id) || typeof entry.name !== 'string' || !Array.isArray(entry.tags)
|
|
43
|
+
|| typeof entry.file !== 'string' || basename(entry.file) !== entry.file || !(entry.mediaType in EXTENSION)
|
|
44
|
+
|| typeof entry.md5 !== 'string' || !/^[a-f0-9]{32}$/.test(entry.md5) || !Number.isSafeInteger(entry.bytes) || !Number.isFinite(entry.addedAt))
|
|
45
|
+
throw new Error(`条目 ${String(entry?.id)} 不正确`);
|
|
46
|
+
}
|
|
47
|
+
return value.stickers;
|
|
48
|
+
}
|
|
49
|
+
/** The world's own sticker library: a folder with `index.json` and the image files, written atomically and one change at a time. */
|
|
50
|
+
export function createStickerLibrary(dir) {
|
|
51
|
+
const indexFile = join(dir, 'index.json'), filesDir = join(dir, 'files');
|
|
52
|
+
let chain = Promise.resolve();
|
|
53
|
+
const serial = (job) => { const next = chain.then(job); chain = next.catch(() => undefined); return next; };
|
|
54
|
+
async function read() {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(indexFile, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 'ENOENT')
|
|
61
|
+
return [];
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return parseIndex(text);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`表情库索引 index.json 无法读取(${error.message}),已保留原文件等待人工修复`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function write(stickers) {
|
|
72
|
+
await mkdir(dir, { recursive: true });
|
|
73
|
+
const temporary = `${indexFile}.tmp`;
|
|
74
|
+
await writeFile(temporary, JSON.stringify({ version: 1, stickers }, null, 2), 'utf8');
|
|
75
|
+
await rename(temporary, indexFile);
|
|
76
|
+
}
|
|
77
|
+
const find = (stickers, id) => {
|
|
78
|
+
const sticker = stickers.find(entry => entry.id === id);
|
|
79
|
+
if (!sticker)
|
|
80
|
+
throw new Error(`没有这个表情:${id}`);
|
|
81
|
+
return sticker;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
dir,
|
|
85
|
+
list: () => serial(read),
|
|
86
|
+
async has(id) { return (await serial(read)).some(sticker => sticker.id === id); },
|
|
87
|
+
/** `existing`: the same image was already in the library, so nothing changed. `id` is only for importing an older library. */
|
|
88
|
+
add: (data, labels, options = {}) => serial(async () => {
|
|
89
|
+
if (data.length > MAX_STICKER_BYTES)
|
|
90
|
+
throw new Error('表情包超过 10MB,过大');
|
|
91
|
+
const mediaType = mediaTypeOf(data);
|
|
92
|
+
const md5 = createHash('md5').update(data).digest('hex');
|
|
93
|
+
if (options.expectedMd5 !== undefined && options.expectedMd5 !== md5)
|
|
94
|
+
throw new Error('下载内容的 md5 与核对时不一致,已丢弃');
|
|
95
|
+
const tags = cleanTags(labels.tags), name = cleanName(labels.name) ?? tags[0];
|
|
96
|
+
const stickers = await read();
|
|
97
|
+
const same = stickers.find(entry => entry.md5 === md5);
|
|
98
|
+
if (same)
|
|
99
|
+
return { sticker: same, existing: true };
|
|
100
|
+
if (stickers.length >= MAX_STICKERS)
|
|
101
|
+
throw new Error(`表情库已满(最多 ${MAX_STICKERS} 张),先移除一些再收`);
|
|
102
|
+
const id = labels.id ?? `st_${md5.slice(0, stickers.some(entry => entry.id === `st_${md5.slice(0, 12)}`) ? 20 : 12)}`;
|
|
103
|
+
if (!idPattern.test(id) || stickers.some(entry => entry.id === id))
|
|
104
|
+
throw new Error(`表情 ID 无效或已被另一张图片使用:${id}`);
|
|
105
|
+
const sticker = { id, name, tags, file: `${id}.${EXTENSION[mediaType]}`, mediaType, md5, bytes: data.length, addedAt: Date.now() };
|
|
106
|
+
await mkdir(filesDir, { recursive: true });
|
|
107
|
+
await writeFile(join(filesDir, sticker.file), data);
|
|
108
|
+
try {
|
|
109
|
+
await write([...stickers, sticker]);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
await rm(join(filesDir, sticker.file), { force: true });
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return { sticker, existing: false };
|
|
116
|
+
}),
|
|
117
|
+
edit: (id, labels) => serial(async () => {
|
|
118
|
+
const stickers = await read();
|
|
119
|
+
const sticker = find(stickers, id);
|
|
120
|
+
const next = { ...sticker, ...(labels.name === undefined ? {} : { name: cleanName(labels.name) }), ...(labels.tags === undefined ? {} : { tags: cleanTags(labels.tags) }) };
|
|
121
|
+
await write(stickers.map(entry => entry === sticker ? next : entry));
|
|
122
|
+
return next;
|
|
123
|
+
}),
|
|
124
|
+
remove: (id) => serial(async () => {
|
|
125
|
+
const stickers = await read();
|
|
126
|
+
const sticker = find(stickers, id);
|
|
127
|
+
await write(stickers.filter(entry => entry !== sticker));
|
|
128
|
+
await rm(join(filesDir, sticker.file), { force: true });
|
|
129
|
+
}),
|
|
130
|
+
/** A few best matches, newest first among equals; a query matches the name and tags, any word counting once. */
|
|
131
|
+
async search(input) {
|
|
132
|
+
const stickers = await serial(read);
|
|
133
|
+
const words = (input.query ?? '').toLowerCase().split(/[\s,,、/;;]+/).filter(Boolean);
|
|
134
|
+
const limit = Math.min(Math.max(Number.isSafeInteger(input.limit) ? input.limit : 8, 1), 20);
|
|
135
|
+
const scored = [...stickers].reverse().map(sticker => {
|
|
136
|
+
const text = [sticker.name, ...sticker.tags].join('\n').toLowerCase();
|
|
137
|
+
return { sticker, score: words.filter(word => text.includes(word)).length };
|
|
138
|
+
}).filter(entry => !words.length || entry.score > 0);
|
|
139
|
+
scored.sort((a, b) => b.score - a.score);
|
|
140
|
+
return { total: stickers.length, items: scored.slice(0, limit).map(entry => entry.sticker) };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** One-time import of the older sticker plugin's folder (`catalog.json` + `assets/`): ids, names and tags carry over, packs and long meanings do not. */
|
|
145
|
+
export async function importLegacyStickers(legacyDir, library) {
|
|
146
|
+
const catalog = JSON.parse(await readFile(join(legacyDir, 'catalog.json'), 'utf8'));
|
|
147
|
+
const report = { imported: 0, duplicates: 0, skipped: [] };
|
|
148
|
+
for (const pack of catalog.packs ?? [])
|
|
149
|
+
for (const entry of pack.stickers ?? []) {
|
|
150
|
+
try {
|
|
151
|
+
if (typeof entry.id !== 'string' || typeof entry.file !== 'string' || basename(entry.file) !== entry.file)
|
|
152
|
+
throw new Error('条目不完整');
|
|
153
|
+
const name = [...String(entry.name ?? '').trim()].slice(0, 30).join('') || undefined;
|
|
154
|
+
const tags = [...new Set((entry.tags ?? []).map(tag => [...String(tag).trim()].slice(0, 16).join('')).filter(Boolean))].slice(0, 8);
|
|
155
|
+
const data = await readFile(join(legacyDir, 'assets', entry.file));
|
|
156
|
+
const result = await library.add(data, { id: entry.id, ...(name ? { name } : {}), tags: tags.length ? tags : [name ?? '表情'] });
|
|
157
|
+
if (result.existing)
|
|
158
|
+
report.duplicates++;
|
|
159
|
+
else
|
|
160
|
+
report.imported++;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
report.skipped.push(`${String(entry?.id)}:${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return report;
|
|
167
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ToolRunContext } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import type { SpeakerIdentity } from '../shared/identity.ts';
|
|
3
|
+
import type { StickerLibrary } from './stickerLibrary.ts';
|
|
4
|
+
/**
|
|
5
|
+
* The model's side of the world's own sticker library. Kept deliberately small: nothing here puts a catalog or a
|
|
6
|
+
* picture in front of the model unless it asked, and sending is the ordinary `wechat_send`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createStickerTools(deps: {
|
|
9
|
+
library: StickerLibrary;
|
|
10
|
+
/** Where the adapter leaves a fetched original until the library has taken it. */
|
|
11
|
+
stagingDir: string;
|
|
12
|
+
target(chatId: string): SpeakerIdentity;
|
|
13
|
+
ask(identity: SpeakerIdentity, fields: {
|
|
14
|
+
chatId: string;
|
|
15
|
+
limit: number;
|
|
16
|
+
stickerId: string;
|
|
17
|
+
}, context: ToolRunContext): Promise<string>;
|
|
18
|
+
}): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
|
+
import { wechatTextOutput } from "./messages.js";
|
|
5
|
+
const messageIdPattern = /^[A-Za-z0-9_.-]{1,64}:\d{1,20}$/;
|
|
6
|
+
const labels = (tags) => tags.join('/');
|
|
7
|
+
/**
|
|
8
|
+
* The model's side of the world's own sticker library. Kept deliberately small: nothing here puts a catalog or a
|
|
9
|
+
* picture in front of the model unless it asked, and sending is the ordinary `wechat_send`.
|
|
10
|
+
*/
|
|
11
|
+
export function createStickerTools(deps) {
|
|
12
|
+
const { library } = deps;
|
|
13
|
+
return [
|
|
14
|
+
defineTool({
|
|
15
|
+
name: 'sticker_search',
|
|
16
|
+
description: '按关键词查表情库(匹配名称、标签),返回 id|名称|标签;不带词看最近收的,limit 默认 8、最多 20。发送用 wechat_send 的 {type:"sticker", stickerId}。',
|
|
17
|
+
parameters: { query: { type: 'string' }, limit: { type: 'integer' } },
|
|
18
|
+
output: wechatTextOutput,
|
|
19
|
+
execute: async ({ query, limit }) => {
|
|
20
|
+
const { total, items } = await library.search({ ...(query === undefined ? {} : { query }), ...(limit === undefined ? {} : { limit }) });
|
|
21
|
+
if (!total)
|
|
22
|
+
return '表情库还是空的,可以用 sticker_save 收别人发来的表情包。';
|
|
23
|
+
if (!items.length)
|
|
24
|
+
return `没有匹配「${query ?? ''}」的表情(库里共 ${total} 张)。换个词,或不带关键词看最近收的。`;
|
|
25
|
+
return `共 ${total} 张,显示 ${items.length} 张:\n` + items.map(sticker => `${sticker.id}|${sticker.name}|${labels(sticker.tags)}`).join('\n');
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
defineTool({
|
|
29
|
+
name: 'sticker_save',
|
|
30
|
+
description: '把聊天里别人发来的表情包收进库。chatId、messageId 取自那条消息;tags 1–8 个短词(每个≤16 字)概括情绪和用法;name 可省。同一张图不重复收。',
|
|
31
|
+
parameters: { chatId: { type: 'string', required: true }, messageId: { type: 'string', required: true }, tags: { type: 'array', items: { type: 'string' }, required: true }, name: { type: 'string' } },
|
|
32
|
+
output: wechatTextOutput,
|
|
33
|
+
execute: async ({ chatId, messageId, tags, name }, context) => {
|
|
34
|
+
if (!messageIdPattern.test(messageId))
|
|
35
|
+
throw new Error('messageId 必须是那条表情消息的 id');
|
|
36
|
+
const identity = deps.target(chatId);
|
|
37
|
+
const staged = JSON.parse(await deps.ask(identity, { chatId, limit: 1, stickerId: messageId }, context));
|
|
38
|
+
const file = join(deps.stagingDir, staged.file);
|
|
39
|
+
try {
|
|
40
|
+
context.signal.throwIfAborted();
|
|
41
|
+
const { sticker, existing } = await library.add(await readFile(file), { tags, ...(name === undefined ? {} : { name }) }, { expectedMd5: staged.md5 });
|
|
42
|
+
return existing ? `库里已经有这张:${sticker.id}「${sticker.name}」(标签 ${labels(sticker.tags)},没有改动)。` : `已收进表情库:${sticker.id}「${sticker.name}」(标签 ${labels(sticker.tags)})。`;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await rm(file, { force: true });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
defineTool({
|
|
50
|
+
name: 'sticker_edit',
|
|
51
|
+
description: '改表情的名称或标签(tags 整个替换)。',
|
|
52
|
+
parameters: { id: { type: 'string', required: true }, name: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } },
|
|
53
|
+
output: wechatTextOutput,
|
|
54
|
+
execute: async ({ id, name, tags }) => {
|
|
55
|
+
if (name === undefined && tags === undefined)
|
|
56
|
+
throw new Error('需要给出新的 name 或 tags');
|
|
57
|
+
const sticker = await library.edit(id, { ...(name === undefined ? {} : { name }), ...(tags === undefined ? {} : { tags }) });
|
|
58
|
+
return `已更新 ${sticker.id}「${sticker.name}」:${labels(sticker.tags)}`;
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
defineTool({
|
|
62
|
+
name: 'sticker_remove',
|
|
63
|
+
description: '删除表情及其图片文件。',
|
|
64
|
+
parameters: { id: { type: 'string', required: true } },
|
|
65
|
+
output: wechatTextOutput,
|
|
66
|
+
execute: async ({ id }) => { await library.remove(id); return `已移出 ${id}。`; },
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { WorldConfig, ConversationRecord, ConversationTurn, ArchiveCursor }
|
|
|
6
6
|
export declare const name = "dsh-world";
|
|
7
7
|
export declare const inject: string[];
|
|
8
8
|
/** What `apply` receives: the schema below fills these defaults. */
|
|
9
|
-
export type Config = WorldConfig & Required<Pick<WorldConfig, 'life' | 'fallbackWakeTime' | 'outlets' | 'bridge' | 'toast' | 'allowedHosts'>>;
|
|
9
|
+
export type Config = WorldConfig & Required<Pick<WorldConfig, 'life' | 'replaceCompaction' | 'fallbackWakeTime' | 'outlets' | 'bridge' | 'toast' | 'allowedHosts'>>;
|
|
10
10
|
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
11
11
|
worldId: z<string, string, "defined">;
|
|
12
12
|
agentPreset: z<string, string, "defined">;
|
|
@@ -14,6 +14,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
14
14
|
companionName: z<string, string, "plain">;
|
|
15
15
|
partnerName: z<string, string, "plain">;
|
|
16
16
|
archiveScope: z<"home" | "workspace" | "world", "home" | "workspace", "defined">;
|
|
17
|
+
replaceCompaction: z<boolean, boolean, "defined">;
|
|
17
18
|
life: z<boolean, boolean, "defined">;
|
|
18
19
|
diaryDir: z<string, string, "plain">;
|
|
19
20
|
fallbackWakeTime: z<string, string, "defined">;
|
|
@@ -108,6 +109,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
108
109
|
companionName: z<string, string, "plain">;
|
|
109
110
|
partnerName: z<string, string, "plain">;
|
|
110
111
|
archiveScope: z<"home" | "workspace" | "world", "home" | "workspace", "defined">;
|
|
112
|
+
replaceCompaction: z<boolean, boolean, "defined">;
|
|
111
113
|
life: z<boolean, boolean, "defined">;
|
|
112
114
|
diaryDir: z<string, string, "plain">;
|
|
113
115
|
fallbackWakeTime: z<string, string, "defined">;
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { consoleRoute } from "./console/routes.js";
|
|
|
8
8
|
import { consoleSources } from "./console/snapshot.js";
|
|
9
9
|
import { createHomeApply } from "./console/homeApply.js";
|
|
10
10
|
import { errorText, Faults } from "./faults.js";
|
|
11
|
+
import { swapCompactionOnce } from "./compactionSwap.js";
|
|
11
12
|
import { hostLife } from "./life/host.js";
|
|
12
13
|
import { createLifeStore, durableWrite } from "./life/lifeStore.js";
|
|
13
14
|
import { toastSupported, windowsToast } from "./life/notify.js";
|
|
@@ -39,6 +40,8 @@ export const Config = z.object({
|
|
|
39
40
|
worldId: z.string().default('default'), agentPreset: z.string().default('standard'), timeZone: z.string().default(systemTimeZone),
|
|
40
41
|
companionName: z.string(), partnerName: z.string(),
|
|
41
42
|
archiveScope: z.union([z.const('home').description('仅家会话'), z.const('workspace').description('家所在工作区'), z.transform(z.const('world').hidden(), () => 'workspace').hidden()]).default('workspace').description('记忆归档范围,修改后重启生效'),
|
|
43
|
+
// Install means the tool-free compaction: the first start rewrites the profile's stock compaction row (see README); false leaves it alone.
|
|
44
|
+
replaceCompaction: z.boolean().default(true),
|
|
42
45
|
life: z.boolean().default(true), diaryDir: z.string(), fallbackWakeTime: z.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).default('09:00'),
|
|
43
46
|
selfFile: z.string(), worldPromptFile: z.string(), alarmTemplateFile: z.string(), personaFile: z.string(),
|
|
44
47
|
// Where her words go outside DSH; no app is known by name (see README for the push body).
|
|
@@ -169,6 +172,8 @@ export async function apply(ctx, config) {
|
|
|
169
172
|
ctx.inject(['sessionController', 'permissionPresets', 'webServer'], async (lifeCtx) => {
|
|
170
173
|
const webServer = lifeCtx.webServer;
|
|
171
174
|
const host = hostLife(lifeCtx);
|
|
175
|
+
swapCompactionOnce({ enabled: config.replaceCompaction, profileDir: lifeCtx.get('profileContext')?.dir, done: power.compactionSwapped, markDone: power.markCompactionSwapped,
|
|
176
|
+
report: message => { faults.set('compaction-swap', message); lifeCtx.logger.warn(`dsh-world: ${message}`); } });
|
|
172
177
|
const permissions = createWorkspacePermissions(lifeCtx, options.roomDir, power);
|
|
173
178
|
// QQ 的两扇门:她自己那条 OneBot 通道的状态与开关,以及 SnowLuma 的控制台。**无条件注册** —— 通道
|
|
174
179
|
// 关着的时候 /native 回 null,而 SnowLuma 那扇门跟通道没关系。(这里原来还有一半是给外面那个
|
|
@@ -308,7 +313,8 @@ export async function apply(ctx, config) {
|
|
|
308
313
|
await lifeStop;
|
|
309
314
|
const outlets = config.outlets.map(outlet => createOutlet(outlet, () => host.origin()));
|
|
310
315
|
const notifier = { ...(config.toast && toastSupported() ? { toast: windowsToast(config.toastAppId || undefined) } : {}), outlets };
|
|
311
|
-
const current = createLifeRuntime({ ctx: lifeCtx, config: { ...config, companionName: names().companion }, options, store: lifeStore, host, notifier, dailyBudget: power.dailyBudget, inWorkspace, photos, alarmContext: now => wechat?.presence(now)
|
|
316
|
+
const current = createLifeRuntime({ ctx: lifeCtx, config: { ...config, companionName: names().companion }, options, store: lifeStore, host, notifier, dailyBudget: power.dailyBudget, inWorkspace, photos, alarmContext: now => wechat?.presence(now), afterReview: () => { if (power.compactAfterReview())
|
|
317
|
+
homeApply.request(); } });
|
|
312
318
|
life = current;
|
|
313
319
|
isHome = current.isHome;
|
|
314
320
|
if (config.bridge) {
|
|
@@ -345,7 +351,7 @@ export async function apply(ctx, config) {
|
|
|
345
351
|
if (config.bridge)
|
|
346
352
|
lifeCtx.effect(() => webServer.register(bridgeRoute(() => bridge, config.allowedHosts)), 'dsh-world bridge route');
|
|
347
353
|
// Her album lives in her room, so the console shows it whether or not the life line is running.
|
|
348
|
-
const sources = consoleSources({ ctx: lifeCtx, config: memoryConfig, archive: store, names, ...(lifeStore ? { lifeStore } : {}), life: () => life, homeApply: homeApply.snapshot, photos, diaryDir: options.diaryDir, running: power.running, dailyBudget: power.dailyBudget, memoryTurns: power.memoryTurns, selectedArchiveScope: power.selectedArchiveScope, workspacePermissions: permissions.snapshot, wechat: () => wechat ? { ...wechat.status(), adapter: adapter?.status() ?? null } : undefined, archiveSession, native: () => qqNative?.status() ?? null,
|
|
354
|
+
const sources = consoleSources({ ctx: lifeCtx, config: memoryConfig, archive: store, names, ...(lifeStore ? { lifeStore } : {}), life: () => life, homeApply: homeApply.snapshot, compactAfterReview: power.compactAfterReview, photos, diaryDir: options.diaryDir, running: power.running, dailyBudget: power.dailyBudget, memoryTurns: power.memoryTurns, selectedArchiveScope: power.selectedArchiveScope, workspacePermissions: permissions.snapshot, wechat: () => wechat ? { ...wechat.status(), adapter: adapter?.status() ?? null } : undefined, archiveSession, native: () => qqNative?.status() ?? null,
|
|
349
355
|
modules: () => ({ wechat: { available: config.wechat, enabled: power.wechatEnabled(), running: wechat !== undefined }, qq: { available: Boolean(config.qqNative?.enabled), enabled: power.qqEnabled(), running: qqNative !== undefined, ...(qqSdk.needsRestart() ? { restartRequired: true } : {}) } }), faults: () => [...faults.list(), ...(memory?.faults() ?? []), ...(bridge?.faults() ?? [])] });
|
|
350
356
|
const actions = {
|
|
351
357
|
async start() {
|
|
@@ -381,6 +387,7 @@ export async function apply(ctx, config) {
|
|
|
381
387
|
setDailyBudget: yuan => { power.setDailyBudget(yuan); sources.invalidate(); },
|
|
382
388
|
setMemoryTurns: turns => { power.setMemoryTurns(turns); sources.invalidate(); },
|
|
383
389
|
setWorkspacePermission: permission => { permissions.set(permission); sources.invalidate(); },
|
|
390
|
+
setCompactAfterReview: on => { power.setCompactAfterReview(on); sources.invalidate(); },
|
|
384
391
|
async setModules(patch) {
|
|
385
392
|
if (patch.wechat !== undefined && !config.wechat)
|
|
386
393
|
return '没有配置微信模块(配置里 wechat: true)';
|
package/lib/life/activity.js
CHANGED
|
@@ -28,6 +28,11 @@ const spoken = {
|
|
|
28
28
|
wechat_chats: () => '看了看微信能发给谁',
|
|
29
29
|
wechat_delivery: () => '查了一条微信送到没有',
|
|
30
30
|
wechat_history: () => '翻了翻微信的聊天记录',
|
|
31
|
+
wechat_image: () => '看了一张微信群里的图片',
|
|
32
|
+
sticker_search: () => '翻了翻表情库',
|
|
33
|
+
sticker_save: () => '把一个表情包收进了表情库',
|
|
34
|
+
sticker_edit: () => '改了改表情库里的表情',
|
|
35
|
+
sticker_remove: () => '整理了一下表情库',
|
|
31
36
|
send_sticker: () => '发了个表情',
|
|
32
37
|
web_search: (args) => {
|
|
33
38
|
const queries = args.queries;
|
package/lib/life/runtime.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface LifeDeps {
|
|
|
19
19
|
timers?: LifeTimers;
|
|
20
20
|
/** CNY her home may spend a day; undefined or absent is no limit. */
|
|
21
21
|
dailyBudget?: () => number | undefined;
|
|
22
|
+
/** Called once each evening look back has been handed to her home (not for alarms or greetings, and not when delivery failed). */
|
|
23
|
+
afterReview?: () => void;
|
|
22
24
|
/** Read-only facts appended after the user's alarm template is rendered. */
|
|
23
25
|
alarmContext?: (now: Date) => string | undefined;
|
|
24
26
|
photos?: () => PhotoStore;
|
package/lib/life/runtime.js
CHANGED
|
@@ -229,7 +229,7 @@ export function createLifeRuntime(deps) {
|
|
|
229
229
|
* A message of the plugin's own, taken off the schedule while it is sent and put back if it fails: it backs
|
|
230
230
|
* off like an alarm, and its turn is never asked about as an early stop. It counts once its turn starts a step.
|
|
231
231
|
*/
|
|
232
|
-
async function sayOwn(what, source, scheduledAt, job, take, putBack, text) {
|
|
232
|
+
async function sayOwn(what, source, scheduledAt, job, take, putBack, text, delivered) {
|
|
233
233
|
try {
|
|
234
234
|
const agent = await home.open();
|
|
235
235
|
if (stopped)
|
|
@@ -240,6 +240,8 @@ export function createLifeRuntime(deps) {
|
|
|
240
240
|
expect(agent, message, what, scheduledAt, source, job, now);
|
|
241
241
|
earlyStop.startsOwnTurn();
|
|
242
242
|
await say(agent, message);
|
|
243
|
+
if (!stopped)
|
|
244
|
+
delivered?.();
|
|
243
245
|
}
|
|
244
246
|
catch (error) {
|
|
245
247
|
ctx.logger.error(error);
|
|
@@ -280,7 +282,7 @@ export function createLifeRuntime(deps) {
|
|
|
280
282
|
return sayOwn('每日回顾', 'review', review.at, { review }, take, () => { state.review = review; }, now => reviewText(review, now, {
|
|
281
283
|
...withValue('conversations', existing('conversations')), ...withValue('activity', existing('activity')), ...withValue('snapshot', snapshot),
|
|
282
284
|
diaryDir: options.diaryDir, selfFile: options.selfFile,
|
|
283
|
-
}, timeZone, options.fallbackWakeTime, alarmMessages(), templateValues()));
|
|
285
|
+
}, timeZone, options.fallbackWakeTime, alarmMessages(), templateValues()), () => deps.afterReview?.());
|
|
284
286
|
}
|
|
285
287
|
async function send(alarm) {
|
|
286
288
|
try {
|
package/lib/runtime.js
CHANGED
|
@@ -46,11 +46,9 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
46
46
|
const event = session.eventAt(seq);
|
|
47
47
|
return event?.type === 'user/message' && event.data.source.kind === memorySource;
|
|
48
48
|
});
|
|
49
|
-
/**
|
|
49
|
+
/** Restore the loop position; recalled blocks are located directly on the current surface. */
|
|
50
50
|
async function readMemoryState(session) {
|
|
51
|
-
const injected = new Map();
|
|
52
51
|
let position = { turn: 1, step: 1 };
|
|
53
|
-
const surface = new Set(session.surface.nodes);
|
|
54
52
|
const handle = await ctx.sessionPersistence.open(session.id, 'read');
|
|
55
53
|
try {
|
|
56
54
|
for (let offset = 0;; offset += 128) {
|
|
@@ -60,9 +58,6 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
60
58
|
for (const event of events) {
|
|
61
59
|
if (event.type === 'step/start')
|
|
62
60
|
position = { turn: event.data.turn, step: event.data.step };
|
|
63
|
-
const block = event.type === 'user/message' && event.data.source.kind === memorySource;
|
|
64
|
-
if (block && surface.has(event.seq))
|
|
65
|
-
injected.set(event.data.id, event.seq);
|
|
66
61
|
}
|
|
67
62
|
}
|
|
68
63
|
}
|
|
@@ -70,20 +65,33 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
70
65
|
await handle.close();
|
|
71
66
|
}
|
|
72
67
|
const inScope = included(session);
|
|
73
|
-
return { reload: true,
|
|
68
|
+
return { reload: true, position, retiring: [], inScope, restart: !inScope && belongsToWorld(session, config) };
|
|
74
69
|
}
|
|
75
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Marks our recalled blocks on the surface for retirement. The empty system node that retires one is accepted by
|
|
72
|
+
* the host only inside an open turn and step, which pre-step and attach are not, so `flushRetired` writes it from
|
|
73
|
+
* the step's request hook, after the step has started and before the request is assembled (#22).
|
|
74
|
+
*/
|
|
76
75
|
function retireMemory(session, state) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
state.
|
|
83
|
-
}
|
|
76
|
+
state.retiring = session.surface.nodes.filter(seq => {
|
|
77
|
+
const event = session.eventAt(seq);
|
|
78
|
+
return event?.type === 'user/message' && event.data.source.kind === memorySource;
|
|
79
|
+
});
|
|
80
|
+
if (state.retiring.length)
|
|
81
|
+
state.restart = true;
|
|
84
82
|
state.reload = true;
|
|
85
83
|
state.pendingId = undefined;
|
|
86
84
|
}
|
|
85
|
+
/** Empty system nodes retire only our recalled blocks and derive no model message; original logs stay intact. */
|
|
86
|
+
function flushRetired(session, state, position) {
|
|
87
|
+
const retiring = state.retiring;
|
|
88
|
+
state.retiring = [];
|
|
89
|
+
for (const seq of retiring) {
|
|
90
|
+
if (!session.surface.nodes.includes(seq))
|
|
91
|
+
continue;
|
|
92
|
+
session.append('system/message', { ...position, message: createSystemMessage('') }, { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
87
95
|
async function attach(agent) {
|
|
88
96
|
const { session } = agent;
|
|
89
97
|
if (memory.has(session) || !mayCarryWorld(session))
|
|
@@ -98,9 +106,9 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
98
106
|
await archive.schedule(session.id);
|
|
99
107
|
}
|
|
100
108
|
const state = await readMemoryState(session);
|
|
109
|
+
// Retiring writes a system/message, which the host only accepts inside an open turn and step; attach runs
|
|
110
|
+
// between turns, so an out-of-scope block is retired by the next pre-step (before any request) instead (#22).
|
|
101
111
|
memory.set(session, state);
|
|
102
|
-
if (!included(session))
|
|
103
|
-
retireMemory(session, state);
|
|
104
112
|
});
|
|
105
113
|
attaching.set(session, job);
|
|
106
114
|
try {
|
|
@@ -110,31 +118,16 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
110
118
|
attaching.delete(session);
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
|
-
/**
|
|
121
|
+
/** Retire old blocks, then admit fresh memory through the host's normal append path (#21). */
|
|
114
122
|
async function freshBlock(session, state) {
|
|
115
|
-
const messages = session.deriveMessages();
|
|
116
123
|
const turns = await history.loadRecentTurns(config.worldId, memoryTurns(), archivedByScope);
|
|
117
124
|
if (!included(session)) {
|
|
118
125
|
retireMemory(session, state);
|
|
119
126
|
return undefined;
|
|
120
127
|
}
|
|
121
|
-
const present = new Set(
|
|
128
|
+
const present = new Set(session.deriveMessages().map(message => message.id));
|
|
122
129
|
const text = renderHistory(excludePresentMessages(turns, present), config.timeZone, names(), config.archiveScope === 'home');
|
|
123
|
-
|
|
124
|
-
for (const [index, message] of oldBlocks.entries()) {
|
|
125
|
-
if (!included(session)) {
|
|
126
|
-
retireMemory(session, state);
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
const seq = state.injected.get(message.id);
|
|
130
|
-
if (seq === undefined || !session.surface.nodes.includes(seq))
|
|
131
|
-
throw new Error('dsh-world: cannot locate retained injected context in the host surface');
|
|
132
|
-
const replacement = createUserMessage({ content: [{ type: 'text', text: index === 0 ? text : '' }], source: { kind: memorySource, form: 'recall' } });
|
|
133
|
-
session.append('user/message', replacement, { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
|
|
134
|
-
state.injected.delete(message.id);
|
|
135
|
-
}
|
|
136
|
-
if (oldBlocks.length)
|
|
137
|
-
return undefined;
|
|
130
|
+
retireMemory(session, state);
|
|
138
131
|
return text ? createUserMessage({ content: [{ type: 'text', text }], source: { kind: memorySource, form: 'recall' } }) : undefined;
|
|
139
132
|
}
|
|
140
133
|
/** Reads what the first catch-up had to leave, once the host can say which sessions belong to her. */
|
|
@@ -152,8 +145,6 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
152
145
|
if (state && event.type === 'step/start')
|
|
153
146
|
state.position = { turn: event.data.turn, step: event.data.step };
|
|
154
147
|
const compacted = event.type === 'compaction/end' && event.data.error === undefined;
|
|
155
|
-
if (state && event.type === 'user/message' && event.data.source.kind === memorySource)
|
|
156
|
-
state.injected.set(event.data.id, event.seq);
|
|
157
148
|
if (state && event.type === 'user/message' && event.data.id === state.pendingId) {
|
|
158
149
|
state.reload = false;
|
|
159
150
|
state.pendingId = undefined;
|
|
@@ -205,6 +196,13 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
205
196
|
state.pendingId = block.id;
|
|
206
197
|
return { ...decision, messages: [block, ...decision.messages] };
|
|
207
198
|
}));
|
|
199
|
+
// The step is open here and its request is not built yet: the one place retired blocks can be written validly.
|
|
200
|
+
disposers.push(ctx.on('agent/request', async function (payload, next) {
|
|
201
|
+
const state = memory.get(payload.agent.session);
|
|
202
|
+
if (state?.retiring.length)
|
|
203
|
+
flushRetired(payload.agent.session, state, { turn: payload.turn, step: payload.step });
|
|
204
|
+
return next();
|
|
205
|
+
}));
|
|
208
206
|
disposers.push(ctx.on('llm/stream', async function* (request, next) {
|
|
209
207
|
const agent = isAgentLoopRequest(request) && request.sessionId ? ctx.agents.get(request.sessionId) : undefined;
|
|
210
208
|
const owned = agent && included(agent.session);
|
|
@@ -212,6 +210,7 @@ export function createWorldRuntime(ctx, config, store, world, memoryTurns = () =
|
|
|
212
210
|
const state = memory.get(agent.session);
|
|
213
211
|
if (state) {
|
|
214
212
|
retireMemory(agent.session, state);
|
|
213
|
+
flushRetired(agent.session, state, state.position);
|
|
215
214
|
state.restart = true;
|
|
216
215
|
}
|
|
217
216
|
yield { type: 'finish', reason: { kind: 'error', failure: { code: 'DSH_WORLD_SCOPE_CHANGED', message: 'dsh-world: 会话已离开世界工作区,已阻止携带世界上下文的请求,请重新发送。' } } };
|
package/lib/types.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface WorldConfig {
|
|
|
10
10
|
archiveScope?: MemoryArchiveScope;
|
|
11
11
|
/** The life line (home session, alarm, tools, console); on by default. */
|
|
12
12
|
life?: boolean;
|
|
13
|
+
/** On the first start in a profile, switch its stock compaction row to the tool-free one (restart to load it); on by default. */
|
|
14
|
+
replaceCompaction?: boolean;
|
|
13
15
|
/** Her diary directory; defaults to `<world>/diary`. */
|
|
14
16
|
diaryDir?: string;
|
|
15
17
|
/** Local HH:MM of the morning wake when no alarm is pending (she set none, or cancelled hers); 09:00 by default. */
|
package/lib/worldSwitch.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type MemoryArchiveScope } from './archiveSelection.ts';
|
|
|
2
2
|
/** How many of the newest turns the memory block carries when nothing else is set. */
|
|
3
3
|
export declare const DEFAULT_MEMORY_TURNS = 200;
|
|
4
4
|
/** WeChat group messages read before a mention, and seconds a private image waits for its companions. */
|
|
5
|
-
export declare const DEFAULT_WECHAT_CONTEXT =
|
|
5
|
+
export declare const DEFAULT_WECHAT_CONTEXT = 5, MAX_WECHAT_CONTEXT = 20;
|
|
6
6
|
export declare const DEFAULT_WECHAT_BUFFER_SECONDS = 3, MAX_WECHAT_BUFFER_SECONDS = 30;
|
|
7
7
|
/**
|
|
8
8
|
* Her world's own settings, changed from the world console and kept in `<world>/world.json` so they outlast a
|
|
@@ -17,6 +17,12 @@ export declare function createWorldSwitch(worldDir: string, configuredScope?: Me
|
|
|
17
17
|
setArchiveScope(scope: MemoryArchiveScope): void;
|
|
18
18
|
workspacePermission: () => string;
|
|
19
19
|
setWorkspacePermission(permission: string): void;
|
|
20
|
+
/** After each evening look back her home is applied and compacted, so the 我是谁 she has just rewritten takes effect; on unless the partner turns it off. */
|
|
21
|
+
compactAfterReview: () => boolean;
|
|
22
|
+
setCompactAfterReview(on: boolean): void;
|
|
23
|
+
/** Whether the profile has been switched to the tool-free compaction (by this plugin or by hand); asked once, so putting the stock one back sticks. */
|
|
24
|
+
compactionSwapped: () => boolean;
|
|
25
|
+
markCompactionSwapped: () => void;
|
|
20
26
|
running: () => boolean;
|
|
21
27
|
set: (running: boolean) => void;
|
|
22
28
|
/** CNY a day for her home; undefined is no limit. */
|