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.
Files changed (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTRIBUTING.md +161 -161
  3. package/INSTALL.md +314 -311
  4. package/README.md +18 -13
  5. package/client/index.js +20 -1
  6. package/lib/compactionSwap.d.ts +29 -0
  7. package/lib/compactionSwap.js +56 -0
  8. package/lib/console/routes.d.ts +2 -0
  9. package/lib/console/routes.js +6 -0
  10. package/lib/console/snapshot.d.ts +1 -0
  11. package/lib/console/snapshot.js +1 -0
  12. package/lib/ecosystem/qq-native/home.js +2 -2
  13. package/lib/ecosystem/shared/context.d.ts +2 -1
  14. package/lib/ecosystem/shared/context.js +8 -1
  15. package/lib/ecosystem/wechat/home.js +4 -4
  16. package/lib/ecosystem/wechat/index.js +13 -4
  17. package/lib/ecosystem/wechat/messages.js +1 -1
  18. package/lib/ecosystem/wechat/reading.d.ts +9 -0
  19. package/lib/ecosystem/wechat/reading.js +61 -12
  20. package/lib/ecosystem/wechat/sending.d.ts +1 -0
  21. package/lib/ecosystem/wechat/sending.js +3 -0
  22. package/lib/ecosystem/wechat/stickerLibrary.d.ts +50 -0
  23. package/lib/ecosystem/wechat/stickerLibrary.js +167 -0
  24. package/lib/ecosystem/wechat/stickerTools.d.ts +18 -0
  25. package/lib/ecosystem/wechat/stickerTools.js +69 -0
  26. package/lib/index.d.ts +3 -1
  27. package/lib/index.js +9 -2
  28. package/lib/life/activity.js +5 -0
  29. package/lib/life/runtime.d.ts +2 -0
  30. package/lib/life/runtime.js +4 -2
  31. package/lib/runtime.js +36 -37
  32. package/lib/types.d.ts +2 -0
  33. package/lib/worldSwitch.d.ts +7 -1
  34. package/lib/worldSwitch.js +12 -1
  35. package/package.json +115 -114
  36. package/scripts/import-stickers.mjs +17 -0
  37. package/src/ecosystem/wechat/windows/README.md +22 -5
  38. package/src/ecosystem/wechat/windows/adapter.py +24 -5
  39. package/src/ecosystem/wechat/windows/groups.py +2 -2
  40. package/src/ecosystem/wechat/windows/native.py +4 -3
  41. package/src/ecosystem/wechat/windows/outgoing.py +14 -7
  42. package/src/ecosystem/wechat/windows/reading.py +58 -1
  43. package/src/ecosystem/wechat/windows/snapshot.py +8 -0
  44. 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)';
@@ -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;
@@ -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;
@@ -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
- /** Locate earlier memory blocks; every runtime start rebuilds from the active scope and real times. */
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, injected, position, inScope, restart: !inScope && belongsToWorld(session, config) };
68
+ return { reload: true, position, retiring: [], inScope, restart: !inScope && belongsToWorld(session, config) };
74
69
  }
75
- /** Empty system nodes retire only our recalled blocks and derive no model message; original logs stay intact. */
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
- for (const [id, seq] of state.injected) {
78
- if (session.surface.nodes.includes(seq)) {
79
- session.append('system/message', { ...state.position, message: createSystemMessage('') }, { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
80
- state.restart = true;
81
- }
82
- state.injected.delete(id);
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
- /** Retires the blocks still on the surface (they must be found, or the session would carry two) and builds a fresh one without the messages already present. */
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(messages.map(message => message.id));
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
- const oldBlocks = messages.filter(message => message.source.kind === memorySource);
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. */
@@ -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 = 8, MAX_WECHAT_CONTEXT = 20;
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. */