@lemoncat7/dsh-partner 1.0.0 → 1.3.9

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 (90) hide show
  1. package/README.md +24 -7
  2. package/lib/agent-runtime.d.ts +53 -4
  3. package/lib/agent-runtime.d.ts.map +1 -1
  4. package/lib/agent-runtime.js +674 -52
  5. package/lib/agent-runtime.js.map +1 -1
  6. package/lib/api.d.ts +2 -0
  7. package/lib/api.d.ts.map +1 -1
  8. package/lib/api.js +29 -2
  9. package/lib/api.js.map +1 -1
  10. package/lib/autonomy.d.ts +3 -0
  11. package/lib/autonomy.d.ts.map +1 -0
  12. package/lib/autonomy.js +44 -0
  13. package/lib/autonomy.js.map +1 -0
  14. package/lib/channel-message.d.ts +23 -0
  15. package/lib/channel-message.d.ts.map +1 -0
  16. package/lib/channel-message.js +8 -0
  17. package/lib/channel-message.js.map +1 -0
  18. package/lib/channels/manager.d.ts +28 -1
  19. package/lib/channels/manager.d.ts.map +1 -1
  20. package/lib/channels/manager.js +230 -15
  21. package/lib/channels/manager.js.map +1 -1
  22. package/lib/channels/weixin/api.d.ts +2 -0
  23. package/lib/channels/weixin/api.d.ts.map +1 -1
  24. package/lib/channels/weixin/api.js +80 -1
  25. package/lib/channels/weixin/api.js.map +1 -1
  26. package/lib/channels/weixin/media.d.ts +4 -0
  27. package/lib/channels/weixin/media.d.ts.map +1 -0
  28. package/lib/channels/weixin/media.js +78 -0
  29. package/lib/channels/weixin/media.js.map +1 -0
  30. package/lib/channels/weixin/types.d.ts +19 -2
  31. package/lib/channels/weixin/types.d.ts.map +1 -1
  32. package/lib/client-api.d.ts +43 -0
  33. package/lib/client-api.d.ts.map +1 -1
  34. package/lib/client-api.js.map +1 -1
  35. package/lib/client.d.ts.map +1 -1
  36. package/lib/client.js +939 -481
  37. package/lib/client.js.map +4 -4
  38. package/lib/concern-domain.d.ts +121 -0
  39. package/lib/concern-domain.d.ts.map +1 -0
  40. package/lib/concern-domain.js +182 -0
  41. package/lib/concern-domain.js.map +1 -0
  42. package/lib/concern-notification.d.ts +6 -0
  43. package/lib/concern-notification.d.ts.map +1 -0
  44. package/lib/concern-notification.js +27 -0
  45. package/lib/concern-notification.js.map +1 -0
  46. package/lib/concern-sources.d.ts +10 -0
  47. package/lib/concern-sources.d.ts.map +1 -0
  48. package/lib/concern-sources.js +81 -0
  49. package/lib/concern-sources.js.map +1 -0
  50. package/lib/concern-store.d.ts +41 -0
  51. package/lib/concern-store.d.ts.map +1 -0
  52. package/lib/concern-store.js +527 -0
  53. package/lib/concern-store.js.map +1 -0
  54. package/lib/daily-review.d.ts +3 -1
  55. package/lib/daily-review.d.ts.map +1 -1
  56. package/lib/daily-review.js +8 -2
  57. package/lib/daily-review.js.map +1 -1
  58. package/lib/domain.d.ts +4 -1
  59. package/lib/domain.d.ts.map +1 -1
  60. package/lib/domain.js +23 -0
  61. package/lib/domain.js.map +1 -1
  62. package/lib/heartbeat.d.ts +10 -2
  63. package/lib/heartbeat.d.ts.map +1 -1
  64. package/lib/heartbeat.js +81 -17
  65. package/lib/heartbeat.js.map +1 -1
  66. package/lib/index.d.ts.map +1 -1
  67. package/lib/index.js +39 -9
  68. package/lib/index.js.map +1 -1
  69. package/lib/memory-domain.d.ts +3 -0
  70. package/lib/memory-domain.d.ts.map +1 -1
  71. package/lib/memory-reflection.d.ts +8 -3
  72. package/lib/memory-reflection.d.ts.map +1 -1
  73. package/lib/memory-reflection.js +66 -13
  74. package/lib/memory-reflection.js.map +1 -1
  75. package/lib/memory-store.d.ts +8 -0
  76. package/lib/memory-store.d.ts.map +1 -1
  77. package/lib/memory-store.js +27 -0
  78. package/lib/memory-store.js.map +1 -1
  79. package/lib/store.d.ts.map +1 -1
  80. package/lib/store.js +39 -3
  81. package/lib/store.js.map +1 -1
  82. package/lib/time-format.d.ts +2 -0
  83. package/lib/time-format.d.ts.map +1 -0
  84. package/lib/time-format.js +20 -0
  85. package/lib/time-format.js.map +1 -0
  86. package/lib/workspace-ownership.d.ts +5 -0
  87. package/lib/workspace-ownership.d.ts.map +1 -0
  88. package/lib/workspace-ownership.js +18 -0
  89. package/lib/workspace-ownership.js.map +1 -0
  90. package/package.json +13 -2
@@ -1,28 +1,61 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { mkdir, realpath, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { BlockAssembler, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
5
+ import { createScope, scopeOf } from '@deepseek-ai/dsh-scope';
6
+ import { concernObservationPrompt } from './autonomy.js';
7
+ import { boundedConcernCheckMinutes } from './concern-domain.js';
8
+ import { PARTNER_MEDIA_MAX_BYTES, safeMediaName } from './channel-message.js';
9
+ import { listConcernFileSources } from './concern-sources.js';
10
+ import { CONCERN_CREATED_NOTICE, renderConcernCreatedNotice } from './concern-notification.js';
11
+ const HEARTBEAT_TIMEOUT_MS = 115_000;
12
+ const HEARTBEAT_DISCOVERY_BUDGET_MS = 72_000;
13
+ const HEARTBEAT_TOOL_TIMEOUT_MS = 18_000;
14
+ const HEARTBEAT_WEB_TOOL_TIMEOUT_MS = 30_000;
15
+ const HEARTBEAT_TOOL_RESULT_LIMIT = 5_000;
16
+ const HEARTBEAT_KNOWLEDGE_RESULT_LIMIT = 8_000;
17
+ const HEARTBEAT_WEB_FETCH_RESULT_LIMIT = 40_000;
18
+ const HEARTBEAT_WEB_SOURCE_RESULT_LIMIT = 125_000;
19
+ const HEARTBEAT_AUDIT_INPUT_LIMIT = 320;
20
+ const HEARTBEAT_AUDIT_OUTPUT_LIMIT = 520;
21
+ const HEARTBEAT_READ_ONLY_TOOLS = new Set([
22
+ 'knowledge_base_search',
23
+ 'knowledge_search',
24
+ 'knowledge_read',
25
+ 'web_search',
26
+ 'web_fetch',
27
+ 'web_source',
28
+ 'glob',
29
+ 'grep',
30
+ 'read',
31
+ ]);
32
+ const HEARTBEAT_FILE_UPDATE_TOOLS = new Set(['write', 'edit', 'str_replace_editor']);
33
+ const HEARTBEAT_TOOLS = new Set([...HEARTBEAT_READ_ONLY_TOOLS, ...HEARTBEAT_FILE_UPDATE_TOOLS]);
5
34
  export class PartnerAgentRuntime {
6
35
  ctx;
7
36
  store;
8
37
  defaultCwd;
9
38
  memory;
10
39
  reflection;
40
+ concerns;
11
41
  handles = new Map();
12
42
  queues = new Map();
43
+ heartbeatQueues = new Map();
44
+ steeringQueues = new Map();
13
45
  workspaces = new Map();
14
- constructor(ctx, store, defaultCwd, memory, reflection) {
46
+ constructor(ctx, store, defaultCwd, memory, reflection, concerns) {
15
47
  this.ctx = ctx;
16
48
  this.store = store;
17
49
  this.defaultCwd = defaultCwd;
18
50
  this.memory = memory;
19
51
  this.reflection = reflection;
52
+ this.concerns = concerns;
20
53
  }
21
- async reply(companion, channelId, userId, text) {
54
+ async reply(companion, channelId, userId, message) {
22
55
  const key = `${channelId}:${userId}`;
23
56
  const previous = this.queues.get(key) ?? Promise.resolve();
24
- let output = '';
25
- const current = previous.catch(() => { }).then(async () => { output = await this.drive(companion, channelId, userId, text); });
57
+ let output = { text: '', attachments: [] };
58
+ const current = previous.catch(() => { }).then(async () => { output = await this.drive(companion, channelId, userId, message); });
26
59
  this.queues.set(key, current);
27
60
  try {
28
61
  await current;
@@ -33,6 +66,46 @@ export class PartnerAgentRuntime {
33
66
  this.queues.delete(key);
34
67
  }
35
68
  }
69
+ async steer(companion, channelId, userId, message) {
70
+ const key = `${channelId}:${userId}`;
71
+ const previous = this.steeringQueues.get(key) ?? Promise.resolve();
72
+ let accepted = false;
73
+ const current = previous.catch(() => { }).then(async () => { accepted = await this.deliverSteering(companion, channelId, userId, message); });
74
+ this.steeringQueues.set(key, current);
75
+ try {
76
+ await current;
77
+ return accepted;
78
+ }
79
+ finally {
80
+ if (this.steeringQueues.get(key) === current)
81
+ this.steeringQueues.delete(key);
82
+ }
83
+ }
84
+ async deliverSteering(companion, channelId, userId, message) {
85
+ const route = this.store.snapshot().sessions.find(item => item.channelId === channelId && item.userId === userId && item.companionId === companion.id);
86
+ if (route === undefined)
87
+ return false;
88
+ const agent = this.handles.get(route.sessionId)?.agent ?? this.ctx.agents.get(route.sessionId);
89
+ if (agent === undefined || agent.status !== 'running')
90
+ return false;
91
+ const inbound = await this.persistInbound(route, message);
92
+ const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
93
+ if (recalled && recalled.length > 0)
94
+ agent.inject(createUserMessage({
95
+ content: [{ type: 'text', text: renderMemory(recalled) }],
96
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴为插话召回了相关长期记忆' },
97
+ }));
98
+ const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
99
+ if (deferred && deferred.length > 0) {
100
+ agent.inject(createUserMessage({
101
+ content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
102
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
103
+ }));
104
+ await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
105
+ }
106
+ agent.steer(createUserMessage({ content: inbound.content, source: { kind: 'user' } }));
107
+ return true;
108
+ }
36
109
  async prepareSession(routeId) {
37
110
  const route = this.store.snapshot().sessions.find(item => item.id === routeId);
38
111
  if (route === undefined)
@@ -58,29 +131,106 @@ export class PartnerAgentRuntime {
58
131
  });
59
132
  return next;
60
133
  }
61
- async heartbeat(companion, route) {
134
+ async heartbeat(companion, route, concerns) {
62
135
  const key = `${route.channelId}:${route.userId}`;
63
- const previous = this.queues.get(key) ?? Promise.resolve();
64
- let output;
65
- const current = previous.catch(() => { }).then(async () => { output = await this.driveHeartbeat(companion, route); });
66
- this.queues.set(key, current);
136
+ const queued = this.heartbeatQueues.get(key);
137
+ if (queued !== undefined)
138
+ return queued;
139
+ const current = this.runHeartbeatWhenIdle(key, companion, route, concerns);
140
+ this.heartbeatQueues.set(key, current);
67
141
  try {
68
- await current;
69
- return output;
142
+ return await current;
70
143
  }
71
144
  finally {
72
- if (this.queues.get(key) === current)
73
- this.queues.delete(key);
145
+ if (this.heartbeatQueues.get(key) === current)
146
+ this.heartbeatQueues.delete(key);
147
+ }
148
+ }
149
+ async persistKnowledgeObservations(companion, route, concerns, observations) {
150
+ const service = this.ctx.get('dshKnowledgeTracking');
151
+ if (service === undefined || observations.length === 0)
152
+ return;
153
+ const agent = await this.ensureAgent(companion, route);
154
+ const byId = new Map(concerns.map(item => [item.id, item]));
155
+ const controller = new AbortController();
156
+ const timeout = setTimeout(() => controller.abort(new Error('知识库观察记录超时')), 30_000);
157
+ timeout.unref?.();
158
+ try {
159
+ for (const observation of observations) {
160
+ if (observation.decision === 'drop')
161
+ continue;
162
+ const concern = byId.get(observation.concernId);
163
+ if (concern === undefined)
164
+ continue;
165
+ const references = concern.resources.filter(item => item.kind === 'knowledge').map(item => item.locator).slice(0, 4);
166
+ if (references.length === 0 && concern.watchKind !== 'knowledge')
167
+ continue;
168
+ for (const reference of references.length > 0 ? references : [undefined]) {
169
+ await service.record(agent, {
170
+ id: observation.id,
171
+ subject: concern.subject,
172
+ event: observation.event,
173
+ evidence: observation.evidence,
174
+ source: observation.source,
175
+ ...(reference === undefined ? {} : { reference }),
176
+ at: observation.createdAt,
177
+ }, controller.signal).catch(error => {
178
+ this.ctx.logger.warn(`dsh-partner: knowledge observation fell back to local storage: ${errorMessage(error)}`);
179
+ });
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ clearTimeout(timeout);
185
+ }
186
+ }
187
+ async concernSources(companion, query = '') {
188
+ const routes = this.store.snapshot().sessions
189
+ .filter(item => item.companionId === companion.id)
190
+ .sort((left, right) => right.lastMessageAt - left.lastMessageAt);
191
+ const root = routes[0]?.cwd ?? partnerCwd(this.defaultCwd, companion.id);
192
+ const files = await listConcernFileSources(root, query, 24);
193
+ const service = this.ctx.get('dshKnowledgeTracking');
194
+ if (service?.list === undefined || routes[0] === undefined)
195
+ return files;
196
+ const controller = new AbortController();
197
+ const timeout = setTimeout(() => controller.abort(new Error('知识文档候选查询超时')), 8_000);
198
+ timeout.unref?.();
199
+ try {
200
+ const route = routes[0];
201
+ const knowledge = await service.list({ session: { id: route.sessionId, header: { cwd: root }, events: [] } }, query, 16, controller.signal).catch(error => {
202
+ this.ctx.logger.warn(`dsh-partner: knowledge concern sources unavailable: ${errorMessage(error)}`);
203
+ return [];
204
+ });
205
+ return [...files, ...knowledge].slice(0, 40);
206
+ }
207
+ finally {
208
+ clearTimeout(timeout);
209
+ }
210
+ }
211
+ async runHeartbeatWhenIdle(key, companion, route, concerns) {
212
+ for (;;) {
213
+ await this.queues.get(key)?.catch(() => { });
214
+ const conversation = await this.ensureAgent(companion, route);
215
+ if (conversation.status !== 'idle')
216
+ await conversation.whenIdle();
217
+ const stableSeq = conversation.session.seq;
218
+ await delay(2_000);
219
+ if (this.queues.has(key))
220
+ continue;
221
+ if (conversation.status !== 'idle' || conversation.session.seq !== stableSeq)
222
+ continue;
223
+ return this.driveScopedHeartbeat(conversation, companion, concerns);
74
224
  }
75
225
  }
76
226
  async observeSessionEvent(session, event) {
77
- if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed' || this.reflection === undefined)
227
+ if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed')
78
228
  return;
79
229
  const route = this.store.snapshot().sessions.find(item => item.sessionId === session.id);
80
230
  if (route === undefined)
81
231
  return;
82
232
  const companion = this.store.snapshot().companions.find(item => item.id === route.companionId);
83
- if (companion === undefined || !companion.automation.memory.enabled)
233
+ if (companion === undefined)
84
234
  return;
85
235
  const events = completedTurnEvents(session.events, event);
86
236
  const prompt = events.find(item => item.type === 'user/message' && item.data.source.kind === 'user');
@@ -89,42 +239,89 @@ export class PartnerAgentRuntime {
89
239
  const assistantText = replies.flatMap(item => item.type === 'assistant/message' ? [textContent(item.data.message.content)] : []).filter(Boolean).join('\n\n');
90
240
  if (!userText || !assistantText)
91
241
  return;
92
- await this.reflection.reflect(companion, {
93
- id: `turn-${randomUUID()}`, companionId: companion.id, scopeId: memoryScope(route.channelId, route.userId),
242
+ const scopeId = memoryScope(route.channelId, route.userId);
243
+ const concernDirective = await this.concerns?.applyUserDirective(companion.id, scopeId, userText, event.time);
244
+ if (!companion.automation.memory.enabled || this.reflection === undefined)
245
+ return;
246
+ const created = await this.reflection.reflect(companion, {
247
+ id: `turn-${randomUUID()}`, companionId: companion.id, scopeId,
94
248
  sessionId: session.id, at: event.time, user: userText, assistant: assistantText,
249
+ ...(concernDirective === undefined ? {} : { concernDirective }),
95
250
  });
251
+ if (created.length > 0)
252
+ await this.recordConcernCreatedNotice(companion, scopeId, created).catch(error => {
253
+ this.ctx.logger.warn(`dsh-partner: automatic concern creation notice failed: ${errorMessage(error)}`);
254
+ });
96
255
  await this.store.update(state => {
97
256
  const target = state.sessions.find(item => item.id === route.id);
98
257
  if (target)
99
258
  target.lastMessageAt = Date.now();
100
259
  });
101
260
  }
102
- async driveHeartbeat(companion, route) {
103
- const agent = await this.ensureAgent(companion, route);
104
- if (agent.status !== 'idle')
105
- return undefined;
106
- const recalled = await this.memory?.recall(companion.id, memoryScope(route.channelId, route.userId), '未完成任务 承诺 跟进 风险 提醒', 16).catch(() => []);
107
- if (recalled && recalled.length > 0)
108
- agent.inject(createUserMessage({
109
- content: [{ type: 'text', text: renderMemory(recalled) }],
110
- source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '心跳召回相关长期记忆' },
111
- }));
112
- const startSeq = agent.session.seq;
113
- agent.followup(createUserMessage({
114
- content: [{ type: 'text', text: [
115
- '执行一次伙伴心跳检查。结合当前会话、召回的结构化记忆、每日回顾和可用工具,判断现在是否确实有一条值得主动告诉用户的信息。',
116
- '检查范围:用户明确交代但尚未完成的事项、承诺过的后续跟进、临近风险,以及通过当前已挂载知识库或工具获得的真正有用的新发现。',
117
- '所有主动报告必须能从当前会话、该联系人的结构化记忆、每日回顾或工具结果中找到依据;不得为了活跃而寒暄,不得虚构进展,不得重复近期提醒。',
118
- '如果没有必要通知,只回复:NO_ACTION',
119
- '如果需要通知,只输出准备发送给用户的简短中文消息,不要解释这是心跳。',
120
- ].join('\n') }],
121
- source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴正在进行低打扰心跳检查' },
122
- }));
123
- await agent.whenIdle();
124
- const response = extractAssistantText(agent, startSeq).trim();
125
- if (!response || /^NO_ACTION[。.!!]?$/i.test(response))
126
- return undefined;
127
- return response;
261
+ async recordConcernCreatedNotice(companion, scopeId, concerns) {
262
+ if (concerns.length === 0)
263
+ return false;
264
+ const route = this.store.snapshot().sessions
265
+ .filter(item => item.companionId === companion.id && memoryScope(item.channelId, item.userId) === scopeId)
266
+ .sort((left, right) => right.lastMessageAt - left.lastMessageAt)[0];
267
+ if (route === undefined)
268
+ return false;
269
+ const conversation = await this.ensureAgent(companion, route);
270
+ if (conversation.status !== 'idle')
271
+ await conversation.whenIdle();
272
+ conversation.session.append('user/message', createUserMessage({
273
+ content: [{ type: 'text', text: renderConcernCreatedNotice(concerns) }],
274
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: CONCERN_CREATED_NOTICE },
275
+ }), { surfaceOp: 'append' });
276
+ return true;
277
+ }
278
+ async driveScopedHeartbeat(conversation, companion, concerns) {
279
+ const scope = await createHeartbeatScope(this.ctx, conversation, concerns);
280
+ try {
281
+ return await this.driveEphemeralHeartbeat(scope.agent, companion, concerns);
282
+ }
283
+ finally {
284
+ await scope.dispose();
285
+ }
286
+ }
287
+ async driveEphemeralHeartbeat(agent, companion, concerns) {
288
+ const controller = new AbortController();
289
+ const timeout = setTimeout(() => controller.abort(new Error('伙伴心跳超过安全时限')), HEARTBEAT_TIMEOUT_MS);
290
+ timeout.unref?.();
291
+ try {
292
+ const execution = await runHeartbeatInference(this.ctx, agent, companion, concerns, controller.signal);
293
+ return controller.signal.aborted
294
+ ? { ...execution, error: '伙伴心跳超过安全时限,已收束并终止' }
295
+ : execution;
296
+ }
297
+ catch (error) {
298
+ const now = Date.now();
299
+ return {
300
+ concerns,
301
+ candidates: [],
302
+ startedAt: now,
303
+ completedAt: now,
304
+ tools: [],
305
+ error: controller.signal.aborted ? '伙伴心跳超过安全时限,已收束并终止' : errorMessage(error),
306
+ };
307
+ }
308
+ finally {
309
+ clearTimeout(timeout);
310
+ }
311
+ }
312
+ async recordHeartbeatActivity(companion, route, execution, outcome, deliveryError) {
313
+ const conversation = await this.ensureAgent(companion, route);
314
+ if (conversation.status !== 'idle')
315
+ await conversation.whenIdle();
316
+ conversation.session.append('user/message', createUserMessage({
317
+ content: [{ type: 'text', text: renderHeartbeatActivity(execution, outcome, deliveryError) }],
318
+ source: {
319
+ kind: 'plugin',
320
+ plugin: '@lemoncat7/dsh-partner',
321
+ form: 'notice',
322
+ summary: '伙伴正在进行低打扰心跳检查',
323
+ },
324
+ }), { surfaceOp: 'append' });
128
325
  }
129
326
  async resetCompanion(companionId) {
130
327
  await this.releaseCompanion(companionId);
@@ -153,21 +350,30 @@ export class PartnerAgentRuntime {
153
350
  async close() {
154
351
  await Promise.all([...this.handles.values()].map(handle => handle.dispose().catch(() => { })));
155
352
  this.handles.clear();
353
+ this.heartbeatQueues.clear();
354
+ this.steeringQueues.clear();
156
355
  }
157
- async drive(companion, channelId, userId, text) {
356
+ async drive(companion, channelId, userId, message) {
158
357
  const session = await this.ensureSession(companion, channelId, userId);
159
358
  const agent = await this.ensureAgent(companion, session);
160
359
  if (agent.status !== 'idle')
161
360
  await agent.whenIdle();
162
- const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), text, 12).catch(() => []);
361
+ const inbound = await this.persistInbound(session, message);
362
+ const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
163
363
  if (recalled && recalled.length > 0)
164
364
  agent.inject(createUserMessage({
165
365
  content: [{ type: 'text', text: renderMemory(recalled) }],
166
366
  source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴回忆了与当前消息相关的长期记忆' },
167
367
  }));
368
+ const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
369
+ if (deferred && deferred.length > 0)
370
+ agent.inject(createUserMessage({
371
+ content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
372
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
373
+ }));
168
374
  const startSeq = agent.session.seq;
169
375
  agent.followup(createUserMessage({
170
- content: [{ type: 'text', text }],
376
+ content: inbound.content,
171
377
  source: { kind: 'user' },
172
378
  }));
173
379
  await agent.whenIdle();
@@ -179,7 +385,35 @@ export class PartnerAgentRuntime {
179
385
  });
180
386
  if (!response)
181
387
  throw new Error('伙伴没有生成可发送的文本回复');
182
- return response;
388
+ if (deferred && deferred.length > 0)
389
+ await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
390
+ return { text: response, attachments: await extractOutboundAttachments(response, session.cwd ?? this.defaultCwd) };
391
+ }
392
+ async persistInbound(session, message) {
393
+ const cwd = session.cwd ?? this.defaultCwd;
394
+ const directory = join(cwd, 'inbound');
395
+ await mkdir(directory, { recursive: true, mode: 0o700 });
396
+ const content = [];
397
+ const notes = [];
398
+ for (const attachment of message.attachments) {
399
+ if (attachment.data.byteLength > PARTNER_MEDIA_MAX_BYTES)
400
+ throw new Error('微信附件超过 64 MB 限制');
401
+ const name = `${Date.now()}-${randomUUID().slice(0, 8)}-${safeMediaName(attachment.name)}`;
402
+ const path = join(directory, name);
403
+ await writeFile(path, attachment.data, { flag: 'wx', mode: 0o600 });
404
+ notes.push(`微信${attachment.kind === 'image' ? '图片' : '文档'}已保存:${path}`);
405
+ if (attachment.kind === 'image') {
406
+ const mediaType = attachment.mediaType;
407
+ const ref = await this.ctx.attachments.saveImage({ data: attachment.data, mediaType, name: attachment.name });
408
+ content.push({ type: 'image', attachment: ref });
409
+ }
410
+ }
411
+ const visibleText = [message.text.trim(), ...notes].filter(Boolean).join('\n\n');
412
+ if (visibleText)
413
+ content.unshift({ type: 'text', text: visibleText });
414
+ if (content.length === 0)
415
+ content.push({ type: 'text', text: '[微信附件消息]' });
416
+ return { content, query: [message.text, ...message.attachments.map(item => item.name)].filter(Boolean).join(' ') };
183
417
  }
184
418
  async ensureSession(companion, channelId, userId) {
185
419
  const existing = this.store.snapshot().sessions.find(item => item.channelId === channelId && item.userId === userId);
@@ -271,6 +505,65 @@ export class PartnerAgentRuntime {
271
505
  await (await workspace).attachSession(route.sessionId);
272
506
  }
273
507
  }
508
+ function delay(milliseconds) {
509
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
510
+ }
511
+ export async function extractOutboundAttachments(text, cwd) {
512
+ const candidates = new Set();
513
+ for (const match of text.matchAll(/\[[^\]]*\]\((?:<)?(file:\/\/)?([^)>]+)(?:>)?\)/g)) {
514
+ const value = decodeURIComponent(match[2] ?? '').trim();
515
+ if (value)
516
+ candidates.add(value);
517
+ }
518
+ for (const match of text.matchAll(/`([^`\n]+)`/g)) {
519
+ const value = match[1]?.trim();
520
+ if (value && mediaTypeFor(value))
521
+ candidates.add(value);
522
+ }
523
+ for (const line of text.split('\n').map(item => item.trim())) {
524
+ const value = line.replace(/^`|`$/g, '');
525
+ if (value.startsWith('/') && !value.includes(' '))
526
+ candidates.add(value);
527
+ }
528
+ const root = await realpath(cwd);
529
+ const result = [];
530
+ const included = new Set();
531
+ for (const candidate of candidates) {
532
+ if (result.length >= 8)
533
+ break;
534
+ const target = resolve(root, candidate);
535
+ let actual;
536
+ try {
537
+ actual = await realpath(target);
538
+ }
539
+ catch {
540
+ continue;
541
+ }
542
+ if (included.has(actual))
543
+ continue;
544
+ const rel = relative(root, actual);
545
+ if (rel.startsWith('..') || rel === '..' || resolve(root, rel) !== actual)
546
+ continue;
547
+ const info = await stat(actual);
548
+ if (!info.isFile() || info.size > PARTNER_MEDIA_MAX_BYTES)
549
+ continue;
550
+ const mediaType = mediaTypeFor(actual);
551
+ if (!mediaType)
552
+ continue;
553
+ included.add(actual);
554
+ result.push({ path: actual, name: basename(actual), mediaType, kind: mediaType.startsWith('image/') ? 'image' : 'file' });
555
+ }
556
+ return result;
557
+ }
558
+ function mediaTypeFor(path) {
559
+ return {
560
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
561
+ '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
562
+ '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
563
+ '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
564
+ '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json', '.zip': 'application/zip',
565
+ }[extname(path).toLowerCase()];
566
+ }
274
567
  export function completedTurnEvents(events, end) {
275
568
  let startSeq;
276
569
  for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -293,6 +586,12 @@ function renderMemory(entries) {
293
586
  ...entries.map(entry => `- [${entry.kind}|置信度 ${entry.confidence.toFixed(2)}] ${entry.subject}:${entry.content}`),
294
587
  ].join('\n');
295
588
  }
589
+ function renderDeferredMentions(entries) {
590
+ return [
591
+ '伙伴之前发现了与用户当前消息相关的新变化。若自然且确实有帮助,可以在本次回答末尾顺手提一句;不要抢占当前问题,也不要声称刚刚搜索。',
592
+ ...entries.map(item => `- ${item.event}${item.evidence ? `(依据:${item.evidence})` : ''}`),
593
+ ].join('\n');
594
+ }
296
595
  export function resolveAgentOptions(defaultModel, companion) {
297
596
  return {
298
597
  ...defaultModel.currentSelection(),
@@ -314,7 +613,327 @@ export function renewedSession(previous, now = Date.now()) {
314
613
  export function partnerCwd(root, companionId) {
315
614
  return join(root, 'partners', companionId);
316
615
  }
317
- function renderPersona(companion) {
616
+ async function createHeartbeatScope(ctx, conversation, concerns) {
617
+ const parent = scopeOf(conversation.ctx);
618
+ if (parent === undefined)
619
+ throw new Error('伙伴心跳无法解析当前 Agent 作用域');
620
+ const availableTools = [...HEARTBEAT_TOOLS].filter(name => ctx.tools.get(name, conversation) !== undefined);
621
+ const policy = heartbeatToolPolicy(concerns, availableTools);
622
+ const allowedTools = [...policy.allowed];
623
+ if (allowedTools.length === 0)
624
+ throw new Error('伙伴心跳没有可用的只读发现工具');
625
+ const cwd = conversation.session.header.cwd;
626
+ if (!cwd)
627
+ throw new Error('伙伴心跳缺少当前伙伴工作目录');
628
+ const fileAccess = await resolveHeartbeatFileAccess(cwd, concerns);
629
+ const agent = {};
630
+ const scope = createScope(ctx, agent, { parent });
631
+ const agentCtx = scope.ctx.extend({ agent });
632
+ Object.assign(agent, {
633
+ id: conversation.id,
634
+ options: conversation.options,
635
+ session: conversation.session,
636
+ inbox: conversation.inbox,
637
+ status: 'idle',
638
+ ctx: agentCtx,
639
+ cancel: () => { },
640
+ whenIdle: async () => { },
641
+ runMaintenance: async (job) => job(new AbortController().signal),
642
+ send: () => { throw new Error('心跳作用域不接受会话消息'); },
643
+ followup: () => { throw new Error('心跳作用域不接受会话消息'); },
644
+ steer: () => { throw new Error('心跳作用域不接受会话消息'); },
645
+ inject: () => { throw new Error('心跳作用域不接受会话消息'); },
646
+ });
647
+ agentCtx.tools.presentAs('native');
648
+ agentCtx.tools.restrict({ allow: allowedTools });
649
+ agentCtx.tools.guard(exec => {
650
+ if (!policy.allowed.has(exec.name))
651
+ return `当前挂念的来源策略不允许调用 ${exec.name}`;
652
+ return heartbeatToolDenial(exec.name, exec.arguments, fileAccess);
653
+ });
654
+ return { agent, dispose: () => scope.dispose() };
655
+ }
656
+ export function heartbeatToolPolicy(concerns, available) {
657
+ const installed = new Set(available);
658
+ const hasLinkedFile = concerns.some(item => item.resources?.some(resource => resource.kind === 'file') === true);
659
+ return {
660
+ allowed: new Set([...installed].filter(name => HEARTBEAT_READ_ONLY_TOOLS.has(name) || (hasLinkedFile && HEARTBEAT_FILE_UPDATE_TOOLS.has(name)))),
661
+ };
662
+ }
663
+ async function runHeartbeatInference(ctx, agent, companion, concerns, signal) {
664
+ const startedAt = Date.now();
665
+ const traces = [];
666
+ const discoveryDeadline = startedAt + HEARTBEAT_DISCOVERY_BUDGET_MS;
667
+ try {
668
+ const selection = resolveAgentOptions(ctx.agentDefaultModel, companion);
669
+ if (!selection.provider || !selection.model)
670
+ throw new Error('伙伴心跳没有可用的模型路由');
671
+ const tools = ctx.tools.schemas(agent).filter(tool => HEARTBEAT_TOOLS.has(tool.name));
672
+ if (tools.length === 0)
673
+ throw new Error('伙伴心跳没有可用的只读发现工具');
674
+ const messages = [createUserMessage({
675
+ content: [{ type: 'text', text: concernObservationPrompt(concerns, agent.session.header.cwd) }],
676
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴观察挂念变化' },
677
+ })];
678
+ for (;;) {
679
+ const discoveryOpen = Date.now() < discoveryDeadline;
680
+ const prepared = await ctx.llm.prepareCall(selection, signal);
681
+ const assembler = new BlockAssembler();
682
+ for await (const chunk of prepared.stream({
683
+ ...prepared.config,
684
+ messages,
685
+ system: renderPersona(companion, 'heartbeat'),
686
+ tools: discoveryOpen ? tools : [],
687
+ signal,
688
+ }))
689
+ assembler.push(chunk);
690
+ const finish = assembler.finish;
691
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
692
+ throw new Error(`伙伴心跳模型调用失败:${finish.failure.message}`);
693
+ }
694
+ const response = assembler.message({ kind: 'model', provider: prepared.config.provider, model: prepared.config.model });
695
+ messages.push(response);
696
+ const pending = response.content.filter((block) => block.type === 'tool-call');
697
+ if (pending.length === 0) {
698
+ const output = textContent(response.content);
699
+ return {
700
+ concerns,
701
+ candidates: parseConcernObservations(output, new Set(concerns.map(item => item.id))),
702
+ startedAt,
703
+ completedAt: Date.now(),
704
+ output,
705
+ tools: traces,
706
+ };
707
+ }
708
+ for (const call of pending) {
709
+ const argumentsValue = boundHeartbeatToolArguments(call.name, parseHeartbeatToolArguments(call));
710
+ const toolStartedAt = Date.now();
711
+ const remainingDiscoveryMs = discoveryDeadline - toolStartedAt;
712
+ const result = remainingDiscoveryMs <= 0
713
+ ? { isError: true, content: [{ type: 'text', text: '本轮发现阶段的时间预算已用完,请根据已有结果立即输出最终 Observation JSON。' }] }
714
+ : await executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs);
715
+ const toolCompletedAt = Date.now();
716
+ const visibleContent = heartbeatToolContent(call.name, result.content);
717
+ traces.push({
718
+ name: call.name,
719
+ input: auditValue(argumentsValue, HEARTBEAT_AUDIT_INPUT_LIMIT),
720
+ output: auditText(textContent(visibleContent) || '工具返回了非文本结果', HEARTBEAT_AUDIT_OUTPUT_LIMIT),
721
+ startedAt: toolStartedAt,
722
+ completedAt: toolCompletedAt,
723
+ status: result.isError ? 'failed' : 'completed',
724
+ });
725
+ messages.push(createToolResultMessage({ callId: call.id, content: visibleContent, isError: result.isError }));
726
+ }
727
+ }
728
+ }
729
+ catch (error) {
730
+ return { concerns, candidates: [], startedAt, completedAt: Date.now(), tools: traces, error: errorMessage(error) };
731
+ }
732
+ }
733
+ export function parseConcernObservations(raw, allowedIds) {
734
+ const match = raw.trim().match(/\{[\s\S]*\}/);
735
+ if (!match)
736
+ throw new Error('伙伴心跳没有返回 Observation JSON');
737
+ const parsed = JSON.parse(match[0]);
738
+ if (!Array.isArray(parsed.observations))
739
+ throw new Error('伙伴心跳 Observation 格式无效');
740
+ const seen = new Set();
741
+ return parsed.observations.flatMap(value => {
742
+ if (!isRecord(value))
743
+ return [];
744
+ const concernId = typeof value.concernId === 'string' ? value.concernId : '';
745
+ if (!allowedIds.has(concernId) || seen.has(concernId))
746
+ return [];
747
+ seen.add(concernId);
748
+ const changed = value.changed === true;
749
+ const nextCheckInMinutes = boundedConcernCheckMinutes(value.nextCheckInMinutes);
750
+ const notificationRuleEffect = value.notificationRuleEffect === 'notify' || value.notificationRuleEffect === 'suppress'
751
+ ? value.notificationRuleEffect
752
+ : 'auto';
753
+ return [{
754
+ concernId,
755
+ changed,
756
+ event: changed && typeof value.event === 'string' ? value.event.trim().slice(0, 800) : '',
757
+ evidence: typeof value.evidence === 'string' ? value.evidence.trim().slice(0, 2_000) : '',
758
+ source: typeof value.source === 'string' ? value.source.trim().slice(0, 240) : '',
759
+ relevance: boundedScore(value.relevance),
760
+ confidence: boundedScore(value.confidence),
761
+ actionability: boundedScore(value.actionability),
762
+ ...(nextCheckInMinutes === undefined ? {} : { nextCheckInMinutes }),
763
+ notificationRuleEffect,
764
+ notificationRuleReason: typeof value.notificationRuleReason === 'string' ? value.notificationRuleReason.trim().slice(0, 800) : '',
765
+ }];
766
+ });
767
+ }
768
+ function boundedScore(value) {
769
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : .5;
770
+ }
771
+ function parseHeartbeatToolArguments(call) {
772
+ try {
773
+ return JSON.parse(call.arguments);
774
+ }
775
+ catch {
776
+ return { __invalidArguments: call.arguments };
777
+ }
778
+ }
779
+ async function resolveHeartbeatFileAccess(cwd, concerns) {
780
+ const root = await realpath(cwd);
781
+ const writable = new Set();
782
+ const locators = concerns.flatMap(item => item.resources ?? []).filter(item => item.kind === 'file').map(item => item.locator);
783
+ for (const locator of new Set(locators)) {
784
+ const lexical = resolve(root, locator);
785
+ if (!pathInside(root, lexical) || isPartnerMemoryPath(relative(root, lexical)))
786
+ continue;
787
+ try {
788
+ const actual = await realpath(lexical);
789
+ const info = await stat(actual);
790
+ if (!info.isFile() || !pathInside(root, actual) || isPartnerMemoryPath(relative(root, actual)))
791
+ continue;
792
+ writable.add(lexical);
793
+ writable.add(actual);
794
+ }
795
+ catch { /* References may become stale; stale files remain read-only and cannot be recreated. */ }
796
+ }
797
+ return { root, writable };
798
+ }
799
+ export function heartbeatToolDenial(name, argumentsValue, access) {
800
+ if (!isRecord(argumentsValue))
801
+ return undefined;
802
+ const isRead = ['glob', 'grep', 'read'].includes(name);
803
+ const isUpdate = HEARTBEAT_FILE_UPDATE_TOOLS.has(name);
804
+ if (!isRead && !isUpdate)
805
+ return undefined;
806
+ const paths = [argumentsValue.path, argumentsValue.file_path].filter((value) => typeof value === 'string');
807
+ if (paths.some(isPartnerMemoryPath))
808
+ return '伙伴心跳不能访问长期记忆、挂念数据库、会话归档或其备份目录;请改查普通工作文件或其他安全来源。';
809
+ if (access && paths.some(value => !pathInside(access.root, resolve(access.root, value))))
810
+ return '伙伴心跳只能访问当前伙伴工作目录内的文件。';
811
+ if (!isUpdate)
812
+ return undefined;
813
+ if (name === 'str_replace_editor' && argumentsValue.command === 'create')
814
+ return '心跳只能更新已经关联且现存的 @文件,不能创建新文件。';
815
+ const target = paths[0];
816
+ if (paths.length !== 1 || target === undefined || !access || !access.writable.has(resolve(access.root, target))) {
817
+ return '心跳只能更新当前挂念明确关联的现存 @文件,不能修改其他文件。';
818
+ }
819
+ return undefined;
820
+ }
821
+ async function executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs) {
822
+ const configuredTimeoutMs = call.name === 'web_fetch' || call.name === 'web_source' ? HEARTBEAT_WEB_TOOL_TIMEOUT_MS : HEARTBEAT_TOOL_TIMEOUT_MS;
823
+ const timeoutMs = Math.max(1, Math.min(configuredTimeoutMs, remainingDiscoveryMs));
824
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
825
+ const toolSignal = AbortSignal.any([signal, timeoutSignal]);
826
+ try {
827
+ return await ctx.tools.execute({ callId: call.id, name: call.name, arguments: argumentsValue, agent, signal: toolSignal });
828
+ }
829
+ catch (error) {
830
+ const message = timeoutSignal.aborted && !signal.aborted
831
+ ? `工具 ${call.name} 超过 ${timeoutMs / 1_000} 秒,已跳过;请按既有规则判断来源不可用,或根据已有结果收束。`
832
+ : `工具 ${call.name} 调用失败:${errorMessage(error)}`;
833
+ return { isError: true, content: [{ type: 'text', text: message }] };
834
+ }
835
+ }
836
+ function pathInside(root, target) {
837
+ const value = relative(root, target);
838
+ return value === '' || (value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value));
839
+ }
840
+ function boundHeartbeatToolArguments(name, value) {
841
+ if (!isRecord(value))
842
+ return value;
843
+ const bounded = { ...value };
844
+ if (name === 'knowledge_base_search' || name === 'knowledge_search')
845
+ bounded.limit = boundedInteger(value.limit, 4);
846
+ if (name === 'knowledge_read')
847
+ bounded.maxChars = boundedInteger(value.maxChars, HEARTBEAT_KNOWLEDGE_RESULT_LIMIT);
848
+ if (name === 'web_source')
849
+ bounded.max_chars = boundedInteger(value.max_chars, 120_000);
850
+ if (name === 'read')
851
+ bounded.limit = boundedInteger(value.limit, 80);
852
+ return bounded;
853
+ }
854
+ function heartbeatToolContent(name, content) {
855
+ let remaining = heartbeatToolResultLimit(name);
856
+ const bounded = [];
857
+ for (const block of content) {
858
+ if (remaining <= 0)
859
+ break;
860
+ if (block.type !== 'text') {
861
+ bounded.push(block);
862
+ continue;
863
+ }
864
+ const sanitized = name === 'glob'
865
+ ? block.text.split('\n').filter(line => !isPartnerMemoryPath(line.trim())).join('\n')
866
+ : block.text;
867
+ const text = sanitized.length <= remaining ? sanitized : `${sanitized.slice(0, Math.max(0, remaining - 1))}…`;
868
+ if (text)
869
+ bounded.push({ ...block, text });
870
+ remaining -= text.length;
871
+ }
872
+ if (bounded.length === 0 && name === 'glob')
873
+ return [{ type: 'text', text: '未发现可供心跳检查的普通工作文件;伙伴记忆与会话归档已从结果中排除。' }];
874
+ return bounded;
875
+ }
876
+ function heartbeatToolResultLimit(name) {
877
+ if (name === 'knowledge_read')
878
+ return HEARTBEAT_KNOWLEDGE_RESULT_LIMIT;
879
+ if (name === 'web_fetch')
880
+ return HEARTBEAT_WEB_FETCH_RESULT_LIMIT;
881
+ if (name === 'web_source')
882
+ return HEARTBEAT_WEB_SOURCE_RESULT_LIMIT;
883
+ return HEARTBEAT_TOOL_RESULT_LIMIT;
884
+ }
885
+ function isPartnerMemoryPath(value) {
886
+ const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '/');
887
+ return /(?:^|\/)(?:memory(?:\/|-backup(?:-|\/)|$)|concerns(?:\/|$))/i.test(normalized);
888
+ }
889
+ function boundedInteger(value, maximum) {
890
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : maximum;
891
+ }
892
+ function isRecord(value) {
893
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
894
+ }
895
+ export function renderHeartbeatActivity(execution, outcome, deliveryError) {
896
+ const status = outcome === 'notified' ? '已通过微信提醒' : outcome === 'quiet' ? '无需主动提醒' : '执行失败';
897
+ const lines = [
898
+ `状态:${status}`,
899
+ `本轮挂念:${execution.concerns.map(item => item.subject).join(';')}`,
900
+ `用时:${formatDuration(execution.completedAt - execution.startedAt)}`,
901
+ `工具:${execution.tools.length > 0 ? `${execution.tools.length} 次调用` : '未调用'}`,
902
+ ];
903
+ execution.tools.forEach((trace, index) => {
904
+ lines.push('', `${index + 1}. ${trace.name} · ${trace.status === 'completed' ? '完成' : '失败'} · ${formatDuration(trace.completedAt - trace.startedAt)}`, `输入:${trace.input}`, `结果:${trace.output}`);
905
+ });
906
+ const changed = execution.observations ?? [];
907
+ const conclusion = execution.error || deliveryError
908
+ ? `失败原因:${deliveryError ?? execution.error}`
909
+ : changed.length > 0
910
+ ? `新变化:\n${changed.map(item => `- ${item.event}(${decisionLabel(item.decision)} · ${Math.round(item.interruptScore * 100)})`).join('\n')}`
911
+ : '最终结论:本轮没有发现经过校验的新变化。';
912
+ lines.push('', conclusion);
913
+ return lines.join('\n');
914
+ }
915
+ function decisionLabel(value) {
916
+ return value === 'notify' ? '已提醒' : value === 'feed' ? '伙伴动态' : value === 'defer' ? '顺手一提' : value === 'remember' ? '静默记下' : '忽略';
917
+ }
918
+ function auditValue(value, limit) {
919
+ try {
920
+ return auditText(JSON.stringify(value), limit);
921
+ }
922
+ catch {
923
+ return auditText(String(value), limit);
924
+ }
925
+ }
926
+ function auditText(value, limit) {
927
+ const text = value.trim().replace(/\n{3,}/g, '\n\n');
928
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
929
+ }
930
+ function formatDuration(milliseconds) {
931
+ if (milliseconds < 1_000)
932
+ return `${Math.max(0, milliseconds)} ms`;
933
+ return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 1 : 0)} 秒`;
934
+ }
935
+ function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
936
+ function renderPersona(companion, surface = 'conversation') {
318
937
  const capabilities = companion.capabilities.length > 0 ? companion.capabilities.join('、') : '由当前 Agent Preset 提供的基础能力';
319
938
  return [
320
939
  `你当前以长期工作伙伴「${companion.name}」的身份工作。`,
@@ -322,7 +941,9 @@ function renderPersona(companion) {
322
941
  companion.description ? `定位:${companion.description}` : '',
323
942
  companion.instructions ? `长期行为准则:\n${companion.instructions}` : '',
324
943
  `用户为此伙伴启用的能力范围:${capabilities}。能力标识不等于授权;只能调用当前会话实际提供且已经通过权限校验的工具。`,
325
- '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
944
+ surface === 'heartbeat'
945
+ ? '这是一轮独立的伙伴心跳,不是用户聊天的延续。只根据本轮真实可读信息行动,最终结果由渠道适配器决定是否发送。'
946
+ : '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
326
947
  ].filter(Boolean).join('\n\n');
327
948
  }
328
949
  export function renderToolProtocol() {
@@ -332,6 +953,7 @@ export function renderToolProtocol() {
332
953
  '`run_code` 的程序只返回完成当前任务所需的精简结果。需要顺序依赖时逐个 `await`,互不依赖的只读调用才可并行。',
333
954
  '若工具结果提示 “only `run_code` is callable directly”,说明路由方式错误;立即在同一轮改用 `run_code` 重试。只有规范重试也失败时,才向用户说明真正的失败原因。',
334
955
  '若当前请求原生暴露了目标工具,则可以直接调用该原生工具;不要臆造未出现在顶层清单或生成 SDK 中的工具。',
956
+ '需要把生成的图片或文档交给用户时,最终回复必须用 Markdown 链接明确引用伙伴工作目录中的文件,例如 `[下载文件](/绝对路径/文件.pdf)`;不要只说“文件在这里”。渠道会在确认真实路径位于伙伴工作目录后发送附件。',
335
957
  ].join('\n');
336
958
  }
337
959
  function extractAssistantText(agent, fromSeq) {