@xmanrui/dsh-im 4.25.0 → 4.27.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.25.0",
3
+ "version": "4.27.0",
4
4
  "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -116,6 +116,7 @@
116
116
  "lib",
117
117
  "plugin-src",
118
118
  "scripts/verify-interface-language.mjs",
119
+ "scripts/verify-injected-context.mjs",
119
120
  "scripts/verify-lan-management.mjs",
120
121
  "scripts/verify-model-setting.mjs",
121
122
  "scripts/verify-package.mjs",
@@ -145,13 +146,9 @@
145
146
  "platform": "web"
146
147
  },
147
148
  "compatibility": {
148
- "dsh": "0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.3-alpha.1 || 0.1.5-alpha.1",
149
+ "dsh": "0.1.7-alpha.1",
149
150
  "dshReleases": {
150
- "0.1.2-alpha.4": "compatible",
151
- "0.1.2-alpha.5": "compatible",
152
- "0.1.2-rc.1": "compatible",
153
- "0.1.3-alpha.1": "compatible",
154
- "0.1.5-alpha.1": "compatible"
151
+ "0.1.7-alpha.1": "compatible"
155
152
  },
156
153
  "profiles": [
157
154
  "web"
@@ -118,21 +118,33 @@ export function ImageInputSettings({ rpcCall }) {
118
118
  ['maxTotalMb', '模型图片总量上限 (MB)'],
119
119
  ['maxImages', '每条消息最多图片数'],
120
120
  ];
121
- return h('section', { className: 'dim-globalSection', 'aria-label': '图片输入', 'aria-busy': phase === 'loading' || phase === 'saving' },
122
- h('h3', null, '图片输入'),
123
- h('p', null, '适用于支持图片的聊天渠道。原图按附件保留时长保存,发送给模型的副本会自动缩放或压缩;无法直接发送时交给模型按文件处理。'),
121
+ return h('section', { className: 'dim-globalSection dim-imageSettings', 'aria-label': '图片输入', 'aria-busy': phase === 'loading' || phase === 'saving' },
122
+ h('div', { className: 'dim-globalHead' },
123
+ h('div', { className: 'dim-globalHeadTitle' },
124
+ h('h3', null, '图片输入'),
125
+ h('div', { className: 'dim-globalTtlHelp' },
126
+ h('button', {
127
+ type: 'button',
128
+ className: 'dim-channelHelpButton dim-globalTtlHelpButton',
129
+ 'aria-label': '查看图片输入说明',
130
+ 'aria-describedby': `${id}-help`,
131
+ }, h('span', { 'aria-hidden': 'true' }, '?')),
132
+ h('div', { id: `${id}-help`, className: 'dim-globalTtlTooltip dim-imageSettingsTooltip', role: 'tooltip' },
133
+ '适用于支持图片的聊天渠道。原图按附件保留时长保存,发送给模型的副本会自动缩放或压缩;无法直接发送时交给模型按文件处理。')))),
124
134
  h('form', { onSubmit: save },
125
- ...fields.map(([key, label]) => h('div', { className: 'dim-globalTtlRow', key },
126
- h('label', { htmlFor: `${id}-${key}` }, label),
127
- h('input', { id: `${id}-${key}`, className: 'dim-globalTtlInput', type: 'number', min: 1, step: 1,
128
- value: values[key], disabled: phase !== 'ready',
129
- onChange: (event) => { setValues((current) => ({ ...current, [key]: event.target.value })); setSaved(false); setError(null); },
130
- }))),
131
- h(GlobalButton, { type: 'submit', kind: 'primary', disabled: phase !== 'ready' }, phase === 'saving' ? '保存中…' : '保存图片设置'),
132
- phase === 'error' ? h(GlobalButton, { onClick: () => void load() }, '重试') : null),
133
- error ? h('p', { role: 'alert' }, error) : null,
134
- saved ? h('p', { role: 'status' }, '已保存') : null,
135
- phase === 'loading' ? h('p', { role: 'status' }, '正在读取图片设置…') : null);
135
+ h('div', { className: 'dim-imageSettingsFields' },
136
+ ...fields.map(([key, label]) => h('div', { className: 'dim-imageSettingsField', key },
137
+ h('label', { htmlFor: `${id}-${key}` }, label),
138
+ h('input', { id: `${id}-${key}`, className: 'dim-globalTtlInput', type: 'number', min: 1, step: 1,
139
+ value: values[key], disabled: phase !== 'ready',
140
+ onChange: (event) => { setValues((current) => ({ ...current, [key]: event.target.value })); setSaved(false); setError(null); },
141
+ })))),
142
+ h('div', { className: 'dim-imageSettingsActions' },
143
+ h(GlobalButton, { type: 'submit', kind: 'primary', className: 'dim-globalSaveButton', disabled: phase !== 'ready' }, phase === 'saving' ? '保存中…' : '保存图片设置'),
144
+ phase === 'error' ? h(GlobalButton, { className: 'dim-globalSaveButton', onClick: () => void load() }, '重试') : null)),
145
+ error ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', 'data-tone': 'error', role: 'alert' }, error) : null,
146
+ saved ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', role: 'status' }, '已保存') : null,
147
+ phase === 'loading' ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', role: 'status' }, '正在读取图片设置…') : null);
136
148
  }
137
149
 
138
150
  export function GlobalSettingsPanel({ rpcCall }) {
@@ -108,6 +108,11 @@ const EN = Object.freeze({
108
108
  '当前版本': 'Current version',
109
109
  'DSH-IM 更新': 'DSH-IM update',
110
110
  '检查更新': 'Check for updates',
111
+ 'DSH 版本兼容说明': 'DSH version compatibility',
112
+ 'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。': 'dsh-im 4.25.0 supports DSH up to v0.1.6-alpha.2.',
113
+ 'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。': 'dsh-im 4.26.0 only supports DSH v0.1.7-alpha.1.',
114
+ 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。': 'dsh-im 4.27.0 only supports DSH v0.1.7-alpha.1 and later.',
115
+ '升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。': 'Before updating to dsh-im 4.26.0, upgrade DSH to v0.1.7-alpha.1. If you use an older DSH version, stay on dsh-im 4.25.0.',
111
116
  '重新检查': 'Check again',
112
117
  '刷新状态': 'Refresh status',
113
118
  '手工更新': 'Manual update',
@@ -613,6 +613,20 @@ const CSS = String.raw`
613
613
  .dim-generalSettingsTab:focus-visible { outline: 2px solid var(--dim-blue); outline-offset: -2px; border-radius: 5px; }
614
614
  .dim-generalSettingsTabPanel { min-width: 0; padding-top: 14px; }
615
615
  .dim-globalSection { min-width: 0; padding: 14px 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
616
+ .dim-globalSection + .dim-globalSection { margin-top: 12px; }
617
+ .dim-imageSettings { container-type: inline-size; }
618
+ .dim-imageSettings .dim-globalHead { position: relative; }
619
+ .dim-imageSettings .dim-globalTtlHelp { position: static; }
620
+ .dim-globalTtlTooltip.dim-imageSettingsTooltip { width: min(320px, 100%); max-width: none; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; white-space: normal; overflow-wrap: anywhere; }
621
+ .dim-imageSettingsFields { min-width: 0; display: grid; gap: 12px; margin-top: 12px; }
622
+ .dim-imageSettingsField { min-width: 0; display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 160px); align-items: center; gap: 6px 16px; }
623
+ .dim-imageSettingsField label { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
624
+ .dim-imageSettingsField .dim-globalTtlInput { width: 100%; min-width: 0; }
625
+ .dim-imageSettingsActions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
626
+ .dim-globalInline.dim-imageSettingsFeedback { margin: 10px 0 0; }
627
+ @container (max-width: 380px) {
628
+ .dim-imageSettingsField { grid-template-columns: minmax(0, 1fr); }
629
+ }
616
630
  .dim-globalHead { min-width: 0; display: flex; align-items: center; }
617
631
  .dim-globalHeadTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
618
632
  .dim-globalHead h3 { min-width: 0; overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; line-height: 22px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -467,6 +467,13 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
467
467
  versionsDiffer && !restartRequired ? h('p', { className: 'dim-updateHint' }, '页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。') : null,
468
468
  canConfirm ? h('p', { className: 'dim-updateHint' },
469
469
  '请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null,
470
+ h('aside', { className: 'dim-updateHint', 'aria-label': 'DSH 版本兼容说明' },
471
+ h('strong', null, 'DSH 版本兼容说明'),
472
+ h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。'),
473
+ h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。'),
474
+ h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。'),
475
+ h('p', { className: 'dim-updateManualHint' },
476
+ '升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。')),
470
477
  h(ManualUpdateCommand, {
471
478
  key: manualCommand ?? 'unavailable', command: manualCommand,
472
479
  disabled: busyAction || activeJob || uncertainInstall,
@@ -5,6 +5,7 @@ import { MatrixConfigStore, MatrixSidecarStore } from '../../../../src/channels/
5
5
  import { MatrixController } from '../../../../src/channels/matrix/matrix-controller.mjs';
6
6
  import { MatrixCryptoStore, matrixCryptoPathFor } from '../../../../src/channels/matrix/matrix-crypto-store.mjs';
7
7
  import { MatrixHarnessClient } from '../../../../src/channels/matrix/matrix-harness-client.mjs';
8
+ import { MatrixRoomHistoryStore, matrixRoomHistoryPathFor } from '../../../../src/channels/matrix/matrix-room-history.mjs';
8
9
  import { MatrixRuntime } from '../../../../src/channels/matrix/matrix-runtime.mjs';
9
10
  import { ConversationStateStore } from '../../../../src/channels/shared/conversation-state-store.mjs';
10
11
  import {
@@ -63,10 +64,13 @@ export async function createProductionController(ctx, config = {}, internals = {
63
64
  const stateStores = new Map();
64
65
  const sidecarStores = new Map();
65
66
  const cryptoStores = new Map();
67
+ const historyStores = new Map();
68
+ const ResolvedRoomHistory = internals.RoomHistoryStore ?? MatrixRoomHistoryStore;
66
69
  const ResolvedCryptoStore = internals.CryptoStore ?? MatrixCryptoStore;
67
70
  const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
68
71
  const sidecarPath = (botId) => resolve(paths.bots, botId, 'matrix.json');
69
72
  const cryptoPath = (botId) => matrixCryptoPathFor(resolve(paths.bots, botId));
73
+ const historyPath = (botId) => matrixRoomHistoryPathFor(resolve(paths.bots, botId));
70
74
  const stateFor = async (botId) => {
71
75
  let state = stateStores.get(botId);
72
76
  if (!state) {
@@ -91,6 +95,14 @@ export async function createProductionController(ctx, config = {}, internals = {
91
95
  }
92
96
  return crypto;
93
97
  };
98
+ const historyFor = async (botId) => {
99
+ let history = historyStores.get(botId);
100
+ if (!history) {
101
+ history = await new ResolvedRoomHistory(historyPath(botId)).load();
102
+ historyStores.set(botId, history);
103
+ }
104
+ return history;
105
+ };
94
106
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
95
107
  const inboundTtl = internals.inboundTtl ?? getInboundTtlRuntime(ctx, config);
96
108
  const inboundTtlService = inboundTtl?.service ?? inboundTtl;
@@ -137,6 +149,7 @@ export async function createProductionController(ctx, config = {}, internals = {
137
149
  });
138
150
  const e2eeMode = String(botConfig?.e2eeMode ?? 'optional').trim().toLowerCase();
139
151
  const cryptoStore = e2eeMode === 'off' ? null : await cryptoFor(botId);
152
+ const roomHistory = await historyFor(botId);
140
153
  return new ResolvedRuntime({
141
154
  config: botConfig,
142
155
  ...(accessToken ? { accessToken } : {}),
@@ -145,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
145
158
  harness: workspaceScope.harness,
146
159
  state: workspaceScope.state,
147
160
  sidecar,
161
+ roomHistory,
148
162
  ...(cryptoStore ? { cryptoStore } : {}),
149
163
  contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
150
164
  accessPolicy: accessPolicyProvider(workspaces, botId, {
@@ -65,7 +65,9 @@ function validDraftTarget(value) {
65
65
  function validPayload(endpoint, payload) {
66
66
  if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
67
67
  if (endpoint === DELIVERY_ENDPOINTS.send) {
68
- return exactKeys(payload, ['botId', 'targetId', 'text'])
68
+ return (exactKeys(payload, ['botId', 'targetId', 'text'])
69
+ || (exactKeys(payload, ['botId', 'targetId', 'text', 'format'])
70
+ && ['plain', 'markdown'].includes(payload.format)))
69
71
  && validBotId(payload.botId) && validTargetId(payload.targetId)
70
72
  && typeof payload.text === 'string' && Boolean(payload.text.trim());
71
73
  }
@@ -128,7 +130,10 @@ export function createDeliveryRpcHandler(service) {
128
130
  try {
129
131
  let value;
130
132
  if (endpoint === DELIVERY_ENDPOINTS.send) {
131
- value = await service.send(payload.botId, payload.targetId, payload.text, { signal });
133
+ value = await service.send(payload.botId, payload.targetId, payload.text, {
134
+ signal,
135
+ ...(payload.format === undefined ? {} : { format: payload.format }),
136
+ });
132
137
  } else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
133
138
  value = await service.listTargets(payload.botId);
134
139
  } else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
@@ -290,7 +290,7 @@ export class DeliveryService {
290
290
  }
291
291
  }
292
292
 
293
- async send(botId, targetIdOrDraft, text, { signal } = {}) {
293
+ async send(botId, targetIdOrDraft, text, { signal, format = 'plain' } = {}) {
294
294
  const id = botIdOf(botId);
295
295
  const targetKey = typeof targetIdOrDraft === 'string'
296
296
  ? targetIdOf(targetIdOrDraft)
@@ -299,6 +299,9 @@ export class DeliveryService {
299
299
  if (typeof text !== 'string' || !text.trim()) {
300
300
  throw deliveryError('bad-request', 'Message text is required');
301
301
  }
302
+ if (format !== 'plain' && format !== 'markdown') {
303
+ throw deliveryError('bad-request', 'Message format must be plain or markdown');
304
+ }
302
305
  cancellation(signal);
303
306
  const adapter = await this.#adapterFor(id);
304
307
  try {
@@ -312,7 +315,10 @@ export class DeliveryService {
312
315
  if (!target) throw deliveryError('unknown-target', 'Unknown target');
313
316
  }
314
317
  cancellation(signal);
315
- await adapter.sendText(id, target, text, { signal });
318
+ await adapter.sendText(id, target, text, {
319
+ signal,
320
+ ...(format === 'markdown' ? { format } : {}),
321
+ });
316
322
  return { sent: true };
317
323
  } catch (error) {
318
324
  if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
@@ -0,0 +1,120 @@
1
+ /** Verify the built Host hook against a built DSH's real JSONL persistence. */
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ import { createImHostPlugin } from '../lib/index.js';
10
+ import {
11
+ captureContextEnhancement, enhanceContextContent, INJECTED_CONTEXT_TAGS,
12
+ } from '../src/channels/shared/context-enhancement.mjs';
13
+
14
+ if (!process.argv[2]) throw new Error('Pass the path of a built DSH checkout.');
15
+ const harnessRoot = resolve(process.argv[2]);
16
+ const fromHarness = createRequire(resolve(harnessRoot, 'packages/session/session-persistence-jsonl/package.json'));
17
+ const load = (name) => import(pathToFileURL(fromHarness.resolve(name)).href);
18
+ const { Context } = await load('@deepseek-ai/cordis');
19
+ const { SESSION_FORMAT_VERSION } = await load('@deepseek-ai/dsh-session');
20
+ const { default: JsonlSessionPersistence } = await load('@deepseek-ai/dsh-session-persistence-jsonl');
21
+ assert.equal(SESSION_FORMAT_VERSION, 4, 'This regression verifies Session format V4.');
22
+
23
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-'));
24
+ const ctx = new Context();
25
+ const source = { channel: 'feishu', senderName: 'Context test' };
26
+ const guidance = 'Reply briefly.';
27
+ const reply = `${INJECTED_CONTEXT_TAGS.replyOpen}${JSON.stringify({
28
+ authorName: 'Quoted author', content: 'Quoted text',
29
+ })}${INJECTED_CONTEXT_TAGS.replyClose}`;
30
+
31
+ function enhanced(content, fields = ['channel', 'senderName'], instructions = '') {
32
+ const snapshot = captureContextEnhancement({
33
+ botId: 'context-test',
34
+ getSettings: () => ({
35
+ group: { enabled: true, fields, guidance: instructions },
36
+ direct: { enabled: false, fields: [], guidance: '' },
37
+ }),
38
+ }, 'group');
39
+ return enhanceContextContent(content, snapshot, () => source);
40
+ }
41
+
42
+ function turnEvents(messages, turn = 1, offset = 0) {
43
+ return [
44
+ { type: 'turn/start', data: { turn } },
45
+ { type: 'step/start', data: { turn, step: 1 } },
46
+ ...messages.map(data => ({ type: 'user/message', data, surfaceOp: 'append' })),
47
+ { type: 'step/end', data: { turn, step: 1 } },
48
+ { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } },
49
+ ].map((event, index) => ({ ...event, seq: offset + index, time: offset + index + 1 }));
50
+ }
51
+
52
+ const cases = [
53
+ ['plain', 'Question', ['user']],
54
+ ['source', enhanced('Question'), ['user', 'notice']],
55
+ ['guidance', enhanced('Question', [], guidance), ['user', 'instructions']],
56
+ ['quote', `${reply}\n\nQuestion`, ['notice', 'user']],
57
+ ['combined', enhanced(`${reply}\n\nQuestion`, ['channel'], guidance),
58
+ ['notice', 'user', 'notice', 'instructions']],
59
+ ['structured', enhanced([{ type: 'text', text: reply }, { type: 'text', text: 'Question' }]),
60
+ ['notice', 'user', 'notice']],
61
+ ];
62
+
63
+ try {
64
+ await ctx.plugin(JsonlSessionPersistence, { root: directory, compression: 'none' });
65
+ for (const [name, value] of Object.entries({
66
+ connection: {}, credentials: {}, typertGateway: { stream() {} },
67
+ sessionController: {}, workspaceController: {},
68
+ })) ctx.provide(name, value);
69
+ // Keep the real bundled context hook; unrelated channel connections stay idle.
70
+ const internals = Object.fromEntries([
71
+ 'Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'WecomApp', 'Qq', 'Slack',
72
+ 'Telegram', 'Discord', 'Whatsapp', 'IMessage', 'Email', 'Matrix', 'Office',
73
+ ].map(name => [`apply${name}`, async () => {}]));
74
+ const host = ctx.plugin(createImHostPlugin({
75
+ ...internals,
76
+ installHostLanguage: () => {},
77
+ installSessionSyncCoordinator: () => {},
78
+ }));
79
+ await host.await();
80
+
81
+ for (const [id, content, expectedForms] of cases) {
82
+ const message = {
83
+ id: `${id}-user`, role: 'user',
84
+ content: typeof content === 'string' ? [{ type: 'text', text: content }] : content,
85
+ source: { kind: 'user', rpcId: `feishu-${id}` },
86
+ };
87
+ const decision = { kind: 'enter', messages: [message] };
88
+ const result = await ctx.waterfall('agent/pre-step', { agent: { session: { id } } },
89
+ async () => decision);
90
+ assert.deepEqual(result.messages.map(entry => entry.source.kind === 'user' ? 'user' : entry.source.form), expectedForms, id);
91
+ const human = result.messages.find(entry => entry.source.kind === 'user');
92
+ assert.equal(human.id, message.id);
93
+ assert.deepEqual(human.source, message.source);
94
+ assert.deepEqual(human.content, [{ type: 'text', text: 'Question' }]);
95
+ for (const context of result.messages.filter(entry => entry !== human)) {
96
+ assert.equal(context.source.kind, 'plugin:dsh-im', id);
97
+ assert.equal(Object.hasOwn(context.source, 'plugin'), false, id);
98
+ if (context.source.form === 'notice') assert.ok(context.source.summary.length > 0, id);
99
+ }
100
+ assert.equal(await ctx.waterfall('agent/pre-step', { agent: { session: { id } } }, async () => result), result);
101
+
102
+ const header = { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, delegationDepth: 0 };
103
+ const events = turnEvents(result.messages);
104
+ const writer = await ctx.sessionPersistence.create(header);
105
+ try { await writer.append(events); } finally { await writer.close(); }
106
+ // Reopen for a second turn, then verify both turns through a fresh read handle.
107
+ const resumed = await ctx.sessionPersistence.open(id, 'write');
108
+ const second = turnEvents([{ ...human, id: `${id}-second` }], 2, events.length);
109
+ try { await resumed.append(second); } finally { await resumed.close(); }
110
+ const reader = await ctx.sessionPersistence.open(id, 'read');
111
+ try {
112
+ assert.deepEqual((await reader.read()).events, [...events, ...second], id);
113
+ } finally { await reader.close(); }
114
+ console.log(`PASS ${id}: bundled hook, persistence, reopen and next turn`);
115
+ }
116
+ } finally {
117
+ try { await ctx.fiber.dispose(); }
118
+ finally { await rm(directory, { recursive: true, force: true }); }
119
+ }
120
+ console.log(`Injected context verified against ${harnessRoot} (format V4).`);
@@ -23,6 +23,7 @@ const required = [
23
23
  'lib/client.js',
24
24
  'bin/dsh-im.mjs',
25
25
  'cordis.patch.yml',
26
+ 'scripts/verify-injected-context.mjs',
26
27
  'README.md',
27
28
  'README.en.md',
28
29
  'PROACTIVE_DELIVERY.md',
@@ -628,7 +628,7 @@ export class FeishuRuntime {
628
628
  });
629
629
  }
630
630
 
631
- async sendProactiveText(target, text, { signal } = {}) {
631
+ async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
632
632
  if (!this.#status.ready || !this.#client) {
633
633
  const error = new Error('飞书机器人尚未连接');
634
634
  error.code = 'bot-not-connected';
@@ -646,13 +646,23 @@ export class FeishuRuntime {
646
646
  error.code = 'invalid-target';
647
647
  throw error;
648
648
  }
649
+ if (format !== 'plain' && format !== 'markdown') {
650
+ const error = new TypeError('Message format must be plain or markdown');
651
+ error.code = 'bad-request';
652
+ throw error;
653
+ }
649
654
  signal?.throwIfAborted();
655
+ // Use the same native Markdown element as chat, without opening a stream
656
+ // or retrying as plain text after a possibly accepted delivery.
657
+ const content = format === 'markdown'
658
+ ? { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } }
659
+ : { text };
650
660
  const response = await this.#client.im.v1.message.create({
651
661
  params: { receive_id_type: receiveIdType },
652
662
  data: {
653
663
  receive_id: receiveId,
654
- msg_type: 'text',
655
- content: JSON.stringify({ text }),
664
+ msg_type: format === 'markdown' ? 'interactive' : 'text',
665
+ content: JSON.stringify(content),
656
666
  },
657
667
  });
658
668
  if (response?.code && response.code !== 0) {
@@ -202,12 +202,13 @@ export class StateStore {
202
202
 
203
203
  async #persist() {
204
204
  const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
205
- this.#writeQueue = this.#writeQueue.then(async () => {
205
+ const operation = this.#writeQueue.then(async () => {
206
206
  await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
207
207
  const temporary = `${this.#path}.tmp`;
208
208
  await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
209
209
  await rename(temporary, this.#path);
210
210
  });
211
- await this.#writeQueue;
211
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
212
+ await operation;
212
213
  }
213
214
  }
@@ -13,6 +13,9 @@ const E2EE_MODES = new Set(['off', 'optional', 'required']);
13
13
  const AUTO_JOIN_MODES = new Set(['authorized', 'all']);
14
14
  const MAX_MESSAGE_LENGTH_BOUNDS = Object.freeze({ min: 500, max: 65_535 });
15
15
  const MAX_MEDIA_BYTES_CEILING = 104_857_600;
16
+ const ROOM_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 1, max: 500 });
17
+ const ROOM_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
18
+ const ROOM_CONTEXT_TZ_BOUNDS = Object.freeze({ min: -840, max: 840 });
16
19
 
17
20
  function cleanString(value) {
18
21
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -179,10 +182,15 @@ export class MatrixConfigStore {
179
182
  const allowedRooms = roomIdList(value.allowedRooms);
180
183
  const freeResponseRooms = roomIdList(value.freeResponseRooms);
181
184
  const ignoreUserPatterns = patternList(value.ignoreUserPatterns);
185
+ const roomContextLimit = boundedInteger(value.roomContextLimit, ROOM_CONTEXT_LIMIT_BOUNDS);
186
+ const roomContextMaxChars = boundedInteger(value.roomContextMaxChars, ROOM_CONTEXT_CHARS_BOUNDS);
187
+ const roomContextTzOffsetMinutes = boundedInteger(value.roomContextTzOffsetMinutes, ROOM_CONTEXT_TZ_BOUNDS);
182
188
  if (!E2EE_MODES.has(e2eeMode) || !AUTO_JOIN_MODES.has(autoJoinInvites)
183
189
  || maxMessageLength === null || maxMediaBytes === null
184
- || allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null) return null;
185
- for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions']) {
190
+ || allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
191
+ || roomContextLimit === null || roomContextMaxChars === null
192
+ || roomContextTzOffsetMinutes === null) return null;
193
+ for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled']) {
186
194
  if (value?.[key] !== undefined && typeof value[key] !== 'boolean') return null;
187
195
  }
188
196
  return Object.freeze({
@@ -208,6 +216,10 @@ export class MatrixConfigStore {
208
216
  ...(allowedRooms !== undefined ? { allowedRooms: Object.freeze(allowedRooms) } : {}),
209
217
  ...(freeResponseRooms !== undefined ? { freeResponseRooms: Object.freeze(freeResponseRooms) } : {}),
210
218
  ...(ignoreUserPatterns !== undefined ? { ignoreUserPatterns: Object.freeze(ignoreUserPatterns) } : {}),
219
+ ...(typeof value.roomContextEnabled === 'boolean' ? { roomContextEnabled: value.roomContextEnabled } : {}),
220
+ ...(roomContextLimit !== undefined ? { roomContextLimit } : {}),
221
+ ...(roomContextMaxChars !== undefined ? { roomContextMaxChars } : {}),
222
+ ...(roomContextTzOffsetMinutes !== undefined ? { roomContextTzOffsetMinutes } : {}),
211
223
  });
212
224
  }
213
225