@lemoncat7/dsh-partner 1.1.6 → 1.4.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.
Files changed (88) hide show
  1. package/README.md +21 -6
  2. package/lib/agent-runtime.d.ts +46 -3
  3. package/lib/agent-runtime.d.ts.map +1 -1
  4. package/lib/agent-runtime.js +599 -50
  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 +44 -4
  9. package/lib/api.js.map +1 -1
  10. package/lib/autonomy.d.ts +2 -7
  11. package/lib/autonomy.d.ts.map +1 -1
  12. package/lib/autonomy.js +40 -28
  13. package/lib/autonomy.js.map +1 -1
  14. package/lib/channels/manager.d.ts +1 -0
  15. package/lib/channels/manager.d.ts.map +1 -1
  16. package/lib/channels/manager.js +11 -3
  17. package/lib/channels/manager.js.map +1 -1
  18. package/lib/client-api.d.ts +60 -0
  19. package/lib/client-api.d.ts.map +1 -1
  20. package/lib/client-api.js.map +1 -1
  21. package/lib/client.d.ts.map +1 -1
  22. package/lib/client.js +1880 -620
  23. package/lib/client.js.map +4 -4
  24. package/lib/concern-domain.d.ts +132 -0
  25. package/lib/concern-domain.d.ts.map +1 -0
  26. package/lib/concern-domain.js +212 -0
  27. package/lib/concern-domain.js.map +1 -0
  28. package/lib/concern-notification.d.ts +6 -0
  29. package/lib/concern-notification.d.ts.map +1 -0
  30. package/lib/concern-notification.js +27 -0
  31. package/lib/concern-notification.js.map +1 -0
  32. package/lib/concern-sources.d.ts +10 -0
  33. package/lib/concern-sources.d.ts.map +1 -0
  34. package/lib/concern-sources.js +81 -0
  35. package/lib/concern-sources.js.map +1 -0
  36. package/lib/concern-store.d.ts +68 -0
  37. package/lib/concern-store.d.ts.map +1 -0
  38. package/lib/concern-store.js +611 -0
  39. package/lib/concern-store.js.map +1 -0
  40. package/lib/concern-tool.d.ts +25 -0
  41. package/lib/concern-tool.d.ts.map +1 -0
  42. package/lib/concern-tool.js +145 -0
  43. package/lib/concern-tool.js.map +1 -0
  44. package/lib/daily-review.d.ts +3 -1
  45. package/lib/daily-review.d.ts.map +1 -1
  46. package/lib/daily-review.js +8 -2
  47. package/lib/daily-review.js.map +1 -1
  48. package/lib/domain.d.ts +4 -1
  49. package/lib/domain.d.ts.map +1 -1
  50. package/lib/domain.js +23 -0
  51. package/lib/domain.js.map +1 -1
  52. package/lib/glass-surface.d.ts +20 -0
  53. package/lib/glass-surface.d.ts.map +1 -0
  54. package/lib/glass-surface.js +119 -0
  55. package/lib/glass-surface.js.map +1 -0
  56. package/lib/heartbeat.d.ts +10 -2
  57. package/lib/heartbeat.d.ts.map +1 -1
  58. package/lib/heartbeat.js +81 -17
  59. package/lib/heartbeat.js.map +1 -1
  60. package/lib/index.d.ts.map +1 -1
  61. package/lib/index.js +36 -5
  62. package/lib/index.js.map +1 -1
  63. package/lib/memory-domain.d.ts +39 -7
  64. package/lib/memory-domain.d.ts.map +1 -1
  65. package/lib/memory-prompt-context.d.ts +20 -0
  66. package/lib/memory-prompt-context.d.ts.map +1 -0
  67. package/lib/memory-prompt-context.js +138 -0
  68. package/lib/memory-prompt-context.js.map +1 -0
  69. package/lib/memory-reflection.d.ts +8 -3
  70. package/lib/memory-reflection.d.ts.map +1 -1
  71. package/lib/memory-reflection.js +80 -19
  72. package/lib/memory-reflection.js.map +1 -1
  73. package/lib/memory-store.d.ts +13 -1
  74. package/lib/memory-store.d.ts.map +1 -1
  75. package/lib/memory-store.js +182 -8
  76. package/lib/memory-store.js.map +1 -1
  77. package/lib/profile-domain.d.ts +7 -0
  78. package/lib/profile-domain.d.ts.map +1 -0
  79. package/lib/profile-domain.js +56 -0
  80. package/lib/profile-domain.js.map +1 -0
  81. package/lib/store.d.ts.map +1 -1
  82. package/lib/store.js +39 -3
  83. package/lib/store.js.map +1 -1
  84. package/lib/time-format.d.ts +2 -0
  85. package/lib/time-format.d.ts.map +1 -0
  86. package/lib/time-format.js +20 -0
  87. package/lib/time-format.js.map +1 -0
  88. package/package.json +3 -1
@@ -1,27 +1,56 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, realpath, stat, writeFile } from 'node:fs/promises';
3
- import { basename, extname, join, relative, resolve } from 'node:path';
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
5
- import { heartbeatMessage, heartbeatPrompt, renderHeartbeatContext } from './autonomy.js';
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';
6
8
  import { PARTNER_MEDIA_MAX_BYTES, safeMediaName } from './channel-message.js';
7
- const HEARTBEAT_TIMEOUT_MS = 120_000;
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]);
8
34
  export class PartnerAgentRuntime {
9
35
  ctx;
10
36
  store;
11
37
  defaultCwd;
12
38
  memory;
13
39
  reflection;
40
+ concerns;
14
41
  handles = new Map();
15
42
  queues = new Map();
16
43
  heartbeatQueues = new Map();
17
44
  steeringQueues = new Map();
18
45
  workspaces = new Map();
19
- constructor(ctx, store, defaultCwd, memory, reflection) {
46
+ profileVersions = new Map();
47
+ constructor(ctx, store, defaultCwd, memory, reflection, concerns) {
20
48
  this.ctx = ctx;
21
49
  this.store = store;
22
50
  this.defaultCwd = defaultCwd;
23
51
  this.memory = memory;
24
52
  this.reflection = reflection;
53
+ this.concerns = concerns;
25
54
  }
26
55
  async reply(companion, channelId, userId, message) {
27
56
  const key = `${channelId}:${userId}`;
@@ -61,12 +90,23 @@ export class PartnerAgentRuntime {
61
90
  if (agent === undefined || agent.status !== 'running')
62
91
  return false;
63
92
  const inbound = await this.persistInbound(route, message);
64
- const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
93
+ const context = await this.memory?.recallContext(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => undefined);
94
+ if (context)
95
+ this.injectProfileUpdate(agent, route.sessionId, context.profile);
96
+ const recalled = context?.relevant ?? [];
65
97
  if (recalled && recalled.length > 0)
66
98
  agent.inject(createUserMessage({
67
- content: [{ type: 'text', text: renderMemory(recalled) }],
99
+ content: [{ type: 'text', text: renderMemory(recalled, context?.connections) }],
68
100
  source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴为插话召回了相关长期记忆' },
69
101
  }));
102
+ const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
103
+ if (deferred && deferred.length > 0) {
104
+ agent.inject(createUserMessage({
105
+ content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
106
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
107
+ }));
108
+ await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
109
+ }
70
110
  agent.steer(createUserMessage({ content: inbound.content, source: { kind: 'user' } }));
71
111
  return true;
72
112
  }
@@ -95,12 +135,12 @@ export class PartnerAgentRuntime {
95
135
  });
96
136
  return next;
97
137
  }
98
- async heartbeat(companion, route) {
138
+ async heartbeat(companion, route, concerns) {
99
139
  const key = `${route.channelId}:${route.userId}`;
100
140
  const queued = this.heartbeatQueues.get(key);
101
141
  if (queued !== undefined)
102
142
  return queued;
103
- const current = this.runHeartbeatWhenIdle(key, companion, route);
143
+ const current = this.runHeartbeatWhenIdle(key, companion, route, concerns);
104
144
  this.heartbeatQueues.set(key, current);
105
145
  try {
106
146
  return await current;
@@ -110,27 +150,91 @@ export class PartnerAgentRuntime {
110
150
  this.heartbeatQueues.delete(key);
111
151
  }
112
152
  }
113
- async runHeartbeatWhenIdle(key, companion, route) {
153
+ async persistKnowledgeObservations(companion, route, concerns, observations) {
154
+ const service = this.ctx.get('dshKnowledgeTracking');
155
+ if (service === undefined || observations.length === 0)
156
+ return;
157
+ const agent = await this.ensureAgent(companion, route);
158
+ const byId = new Map(concerns.map(item => [item.id, item]));
159
+ const controller = new AbortController();
160
+ const timeout = setTimeout(() => controller.abort(new Error('知识库观察记录超时')), 30_000);
161
+ timeout.unref?.();
162
+ try {
163
+ for (const observation of observations) {
164
+ if (observation.decision === 'drop')
165
+ continue;
166
+ const concern = byId.get(observation.concernId);
167
+ if (concern === undefined)
168
+ continue;
169
+ const references = concern.resources.filter(item => item.kind === 'knowledge').map(item => item.locator).slice(0, 4);
170
+ if (references.length === 0 && concern.watchKind !== 'knowledge')
171
+ continue;
172
+ for (const reference of references.length > 0 ? references : [undefined]) {
173
+ await service.record(agent, {
174
+ id: observation.id,
175
+ subject: concern.subject,
176
+ event: observation.event,
177
+ evidence: observation.evidence,
178
+ source: observation.source,
179
+ ...(reference === undefined ? {} : { reference }),
180
+ at: observation.createdAt,
181
+ }, controller.signal).catch(error => {
182
+ this.ctx.logger.warn(`dsh-partner: knowledge observation fell back to local storage: ${errorMessage(error)}`);
183
+ });
184
+ }
185
+ }
186
+ }
187
+ finally {
188
+ clearTimeout(timeout);
189
+ }
190
+ }
191
+ async concernSources(companion, query = '') {
192
+ const routes = this.store.snapshot().sessions
193
+ .filter(item => item.companionId === companion.id)
194
+ .sort((left, right) => right.lastMessageAt - left.lastMessageAt);
195
+ const root = routes[0]?.cwd ?? partnerCwd(this.defaultCwd, companion.id);
196
+ const files = await listConcernFileSources(root, query, 24);
197
+ const service = this.ctx.get('dshKnowledgeTracking');
198
+ if (service?.list === undefined || routes[0] === undefined)
199
+ return files;
200
+ const controller = new AbortController();
201
+ const timeout = setTimeout(() => controller.abort(new Error('知识文档候选查询超时')), 8_000);
202
+ timeout.unref?.();
203
+ try {
204
+ const route = routes[0];
205
+ const knowledge = await service.list({ session: { id: route.sessionId, header: { cwd: root }, events: [] } }, query, 16, controller.signal).catch(error => {
206
+ this.ctx.logger.warn(`dsh-partner: knowledge concern sources unavailable: ${errorMessage(error)}`);
207
+ return [];
208
+ });
209
+ return [...files, ...knowledge].slice(0, 40);
210
+ }
211
+ finally {
212
+ clearTimeout(timeout);
213
+ }
214
+ }
215
+ async runHeartbeatWhenIdle(key, companion, route, concerns) {
114
216
  for (;;) {
115
217
  await this.queues.get(key)?.catch(() => { });
116
- const agent = await this.ensureAgent(companion, route);
117
- if (agent.status !== 'idle')
118
- await agent.whenIdle();
119
- const stableSeq = agent.session.seq;
218
+ const conversation = await this.ensureAgent(companion, route);
219
+ if (conversation.status !== 'idle')
220
+ await conversation.whenIdle();
221
+ const stableSeq = conversation.session.seq;
120
222
  await delay(2_000);
121
- if (this.queues.has(key) || agent.status !== 'idle' || agent.session.seq !== stableSeq)
223
+ if (this.queues.has(key))
224
+ continue;
225
+ if (conversation.status !== 'idle' || conversation.session.seq !== stableSeq)
122
226
  continue;
123
- return this.driveHeartbeat(agent);
227
+ return this.driveScopedHeartbeat(conversation, companion, concerns);
124
228
  }
125
229
  }
126
230
  async observeSessionEvent(session, event) {
127
- if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed' || this.reflection === undefined)
231
+ if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed')
128
232
  return;
129
233
  const route = this.store.snapshot().sessions.find(item => item.sessionId === session.id);
130
234
  if (route === undefined)
131
235
  return;
132
236
  const companion = this.store.snapshot().companions.find(item => item.id === route.companionId);
133
- if (companion === undefined || !companion.automation.memory.enabled)
237
+ if (companion === undefined)
134
238
  return;
135
239
  const events = completedTurnEvents(session.events, event);
136
240
  const prompt = events.find(item => item.type === 'user/message' && item.data.source.kind === 'user');
@@ -139,42 +243,98 @@ export class PartnerAgentRuntime {
139
243
  const assistantText = replies.flatMap(item => item.type === 'assistant/message' ? [textContent(item.data.message.content)] : []).filter(Boolean).join('\n\n');
140
244
  if (!userText || !assistantText)
141
245
  return;
142
- await this.reflection.reflect(companion, {
143
- id: `turn-${randomUUID()}`, companionId: companion.id, scopeId: memoryScope(route.channelId, route.userId),
246
+ const scopeId = memoryScope(route.channelId, route.userId);
247
+ const concernDirective = await this.concerns?.applyUserDirective(companion.id, scopeId, userText, event.time);
248
+ if (!companion.automation.memory.enabled || this.reflection === undefined)
249
+ return;
250
+ const created = await this.reflection.reflect(companion, {
251
+ id: `turn-${randomUUID()}`, companionId: companion.id, scopeId,
144
252
  sessionId: session.id, at: event.time, user: userText, assistant: assistantText,
253
+ ...(concernDirective === undefined ? {} : { concernDirective }),
145
254
  });
255
+ const pendingToolNotices = await this.concerns?.pendingToolCreationNotices(companion.id, scopeId, session.id).catch(() => ({ auditIds: [], concerns: [] }));
256
+ const notices = uniqueConcerns([...(pendingToolNotices?.concerns ?? []), ...created]);
257
+ if (notices.length > 0) {
258
+ const delivered = await this.recordConcernCreatedNotice(companion, scopeId, notices).catch(error => {
259
+ this.ctx.logger.warn(`dsh-partner: automatic concern creation notice failed: ${errorMessage(error)}`);
260
+ return false;
261
+ });
262
+ if (delivered && pendingToolNotices && pendingToolNotices.auditIds.length > 0) {
263
+ await this.concerns?.markCreationAuditsNotified(companion.id, pendingToolNotices.auditIds).catch(error => {
264
+ this.ctx.logger.warn(`dsh-partner: concern tool notice audit failed: ${errorMessage(error)}`);
265
+ });
266
+ }
267
+ }
146
268
  await this.store.update(state => {
147
269
  const target = state.sessions.find(item => item.id === route.id);
148
270
  if (target)
149
271
  target.lastMessageAt = Date.now();
150
272
  });
151
273
  }
152
- async driveHeartbeat(agent) {
153
- const tools = this.ctx.tools.schemas(agent);
154
- agent.inject(createUserMessage({
155
- content: [{ type: 'text', text: renderHeartbeatContext({ tools }) }],
156
- source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: `心跳已注入 ${tools.length} 项可用工具` },
157
- }));
158
- const startSeq = agent.session.seq;
159
- agent.followup(createUserMessage({
160
- content: [{ type: 'text', text: heartbeatPrompt() }],
161
- source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴正在进行低打扰心跳检查' },
162
- }));
163
- let timedOut = false;
164
- const timeout = setTimeout(() => {
165
- timedOut = true;
166
- agent.cancel({ kind: 'hook', reason: 'dsh-partner heartbeat exceeded its two-minute budget' }, { keepInbox: true });
167
- }, HEARTBEAT_TIMEOUT_MS);
274
+ async recordConcernCreatedNotice(companion, scopeId, concerns) {
275
+ if (concerns.length === 0)
276
+ return false;
277
+ const route = this.store.snapshot().sessions
278
+ .filter(item => item.companionId === companion.id && memoryScope(item.channelId, item.userId) === scopeId)
279
+ .sort((left, right) => right.lastMessageAt - left.lastMessageAt)[0];
280
+ if (route === undefined)
281
+ return false;
282
+ const conversation = await this.ensureAgent(companion, route);
283
+ if (conversation.status !== 'idle')
284
+ await conversation.whenIdle();
285
+ conversation.session.append('user/message', createUserMessage({
286
+ content: [{ type: 'text', text: renderConcernCreatedNotice(concerns) }],
287
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: CONCERN_CREATED_NOTICE },
288
+ }), { surfaceOp: 'append' });
289
+ return true;
290
+ }
291
+ async driveScopedHeartbeat(conversation, companion, concerns) {
292
+ const scope = await createHeartbeatScope(this.ctx, conversation, concerns);
293
+ try {
294
+ return await this.driveEphemeralHeartbeat(scope.agent, companion, concerns);
295
+ }
296
+ finally {
297
+ await scope.dispose();
298
+ }
299
+ }
300
+ async driveEphemeralHeartbeat(agent, companion, concerns) {
301
+ const controller = new AbortController();
302
+ const timeout = setTimeout(() => controller.abort(new Error('伙伴心跳超过安全时限')), HEARTBEAT_TIMEOUT_MS);
168
303
  timeout.unref?.();
169
304
  try {
170
- await agent.whenIdle();
305
+ const execution = await runHeartbeatInference(this.ctx, agent, companion, concerns, controller.signal);
306
+ return controller.signal.aborted
307
+ ? { ...execution, error: '伙伴心跳超过安全时限,已收束并终止' }
308
+ : execution;
309
+ }
310
+ catch (error) {
311
+ const now = Date.now();
312
+ return {
313
+ concerns,
314
+ candidates: [],
315
+ startedAt: now,
316
+ completedAt: now,
317
+ tools: [],
318
+ error: controller.signal.aborted ? '伙伴心跳超过安全时限,已收束并终止' : errorMessage(error),
319
+ };
171
320
  }
172
321
  finally {
173
322
  clearTimeout(timeout);
174
323
  }
175
- if (timedOut)
176
- throw new Error('伙伴心跳超过两分钟,已安全终止');
177
- return heartbeatMessage(extractAssistantText(agent, startSeq));
324
+ }
325
+ async recordHeartbeatActivity(companion, route, execution, outcome, deliveryError) {
326
+ const conversation = await this.ensureAgent(companion, route);
327
+ if (conversation.status !== 'idle')
328
+ await conversation.whenIdle();
329
+ conversation.session.append('user/message', createUserMessage({
330
+ content: [{ type: 'text', text: renderHeartbeatActivity(execution, outcome, deliveryError) }],
331
+ source: {
332
+ kind: 'plugin',
333
+ plugin: '@lemoncat7/dsh-partner',
334
+ form: 'notice',
335
+ summary: '伙伴正在进行低打扰心跳检查',
336
+ },
337
+ }), { surfaceOp: 'append' });
178
338
  }
179
339
  async resetCompanion(companionId) {
180
340
  await this.releaseCompanion(companionId);
@@ -188,6 +348,7 @@ export class PartnerAgentRuntime {
188
348
  await Promise.all(sessions.map(async (item) => {
189
349
  const handle = this.handles.get(item.sessionId);
190
350
  this.handles.delete(item.sessionId);
351
+ this.profileVersions.delete(item.sessionId);
191
352
  await handle?.dispose().catch(() => { });
192
353
  }));
193
354
  }
@@ -196,6 +357,7 @@ export class PartnerAgentRuntime {
196
357
  await Promise.all(sessions.map(async (item) => {
197
358
  const handle = this.handles.get(item.sessionId);
198
359
  this.handles.delete(item.sessionId);
360
+ this.profileVersions.delete(item.sessionId);
199
361
  await handle?.dispose().catch(() => { });
200
362
  }));
201
363
  await this.store.update(state => { state.sessions = state.sessions.filter(item => item.channelId !== channelId); });
@@ -203,6 +365,7 @@ export class PartnerAgentRuntime {
203
365
  async close() {
204
366
  await Promise.all([...this.handles.values()].map(handle => handle.dispose().catch(() => { })));
205
367
  this.handles.clear();
368
+ this.profileVersions.clear();
206
369
  this.heartbeatQueues.clear();
207
370
  this.steeringQueues.clear();
208
371
  }
@@ -212,12 +375,21 @@ export class PartnerAgentRuntime {
212
375
  if (agent.status !== 'idle')
213
376
  await agent.whenIdle();
214
377
  const inbound = await this.persistInbound(session, message);
215
- const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
378
+ const context = await this.memory?.recallContext(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => undefined);
379
+ if (context)
380
+ this.injectProfileUpdate(agent, session.sessionId, context.profile);
381
+ const recalled = context?.relevant ?? [];
216
382
  if (recalled && recalled.length > 0)
217
383
  agent.inject(createUserMessage({
218
- content: [{ type: 'text', text: renderMemory(recalled) }],
384
+ content: [{ type: 'text', text: renderMemory(recalled, context?.connections) }],
219
385
  source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴回忆了与当前消息相关的长期记忆' },
220
386
  }));
387
+ const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
388
+ if (deferred && deferred.length > 0)
389
+ agent.inject(createUserMessage({
390
+ content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
391
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
392
+ }));
221
393
  const startSeq = agent.session.seq;
222
394
  agent.followup(createUserMessage({
223
395
  content: inbound.content,
@@ -232,6 +404,8 @@ export class PartnerAgentRuntime {
232
404
  });
233
405
  if (!response)
234
406
  throw new Error('伙伴没有生成可发送的文本回复');
407
+ if (deferred && deferred.length > 0)
408
+ await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
235
409
  return { text: response, attachments: await extractOutboundAttachments(response, session.cwd ?? this.defaultCwd) };
236
410
  }
237
411
  async persistInbound(session, message) {
@@ -283,14 +457,19 @@ export class PartnerAgentRuntime {
283
457
  async ensureAgent(companion, route) {
284
458
  const cwd = route.cwd ?? this.defaultCwd;
285
459
  await mkdir(cwd, { recursive: true, mode: 0o700 });
460
+ const profile = await this.memory?.profileSnapshot(companion.id, memoryScope(route.channelId, route.userId)).catch(() => undefined);
286
461
  const held = this.handles.get(route.sessionId);
287
462
  if (held !== undefined) {
288
463
  await this.attachSession(companion, route);
464
+ if (profile)
465
+ this.injectProfileUpdate(held.agent, route.sessionId, profile);
289
466
  return held.agent;
290
467
  }
291
468
  const live = this.ctx.agents.get(route.sessionId);
292
469
  if (live !== undefined) {
293
470
  await this.attachSession(companion, route);
471
+ if (profile)
472
+ this.injectProfileUpdate(live, route.sessionId, profile);
294
473
  return live;
295
474
  }
296
475
  const options = resolveAgentOptions(this.ctx.agentDefaultModel, companion);
@@ -299,7 +478,6 @@ export class PartnerAgentRuntime {
299
478
  if (presets === undefined)
300
479
  throw new Error('伙伴会话缺少 Agent Presets 服务');
301
480
  await presets.mount(agentCtx, companion.presetId);
302
- const memory = await this.memory?.recall(companion.id, memoryScope(route.channelId, route.userId), '', 12).catch(() => []);
303
481
  agentCtx.systemPrompt.section({
304
482
  name: 'partner-identity',
305
483
  order: -10,
@@ -310,11 +488,11 @@ export class PartnerAgentRuntime {
310
488
  order: -9,
311
489
  text: renderToolProtocol(),
312
490
  });
313
- if (memory && memory.length > 0)
491
+ if (profile && profile.entries.length > 0)
314
492
  agentCtx.systemPrompt.section({
315
- name: 'partner-long-term-memory',
493
+ name: 'partner-user-profile',
316
494
  order: -8,
317
- text: renderMemory(memory),
495
+ text: renderProfile(profile),
318
496
  });
319
497
  };
320
498
  let handle;
@@ -337,9 +515,23 @@ export class PartnerAgentRuntime {
337
515
  });
338
516
  }
339
517
  this.handles.set(route.sessionId, handle);
518
+ if (profile)
519
+ this.profileVersions.set(route.sessionId, profile.version);
340
520
  await this.attachSession(companion, route);
341
521
  return handle.agent;
342
522
  }
523
+ injectProfileUpdate(agent, sessionId, profile) {
524
+ const previous = this.profileVersions.get(sessionId);
525
+ if (previous === profile.version)
526
+ return;
527
+ this.profileVersions.set(sessionId, profile.version);
528
+ if (previous === undefined && profile.entries.length === 0)
529
+ return;
530
+ agent.inject(createUserMessage({
531
+ content: [{ type: 'text', text: renderProfile(profile, previous !== undefined) }],
532
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: previous === undefined ? '伙伴载入了联系人画像' : '伙伴的人物画像已更新' },
533
+ }));
534
+ }
343
535
  async attachSession(companion, route) {
344
536
  const cwd = route.cwd ?? this.defaultCwd;
345
537
  let workspace = this.workspaces.get(cwd);
@@ -425,10 +617,45 @@ export function completedTurnEvents(events, end) {
425
617
  return events.filter(item => item.seq > startSeq && item.seq < end.seq);
426
618
  }
427
619
  export function memoryScope(channelId, userId) { return `${channelId}:${userId}`; }
428
- function renderMemory(entries) {
429
- return [
620
+ function uniqueConcerns(items) {
621
+ const seen = new Set();
622
+ return items.filter(item => {
623
+ if (seen.has(item.id))
624
+ return false;
625
+ seen.add(item.id);
626
+ return true;
627
+ }).slice(0, 4);
628
+ }
629
+ export function renderMemory(entries, connections = []) {
630
+ const lines = [
430
631
  '以下是系统按当前话题召回的结构化长期记忆。仅在相关时使用;不得扩写成记忆中没有的事实。',
431
632
  ...entries.map(entry => `- [${entry.kind}|置信度 ${entry.confidence.toFixed(2)}] ${entry.subject}:${entry.content}`),
633
+ ];
634
+ if (connections.length > 0)
635
+ lines.push('', '以下是一跳记忆关系,用于理解依赖、依据和上下文,不代表可以创造新的事实:', ...connections.map(({ relation, source, target }) => `- ${source.subject} ${memoryRelationLabel(relation.kind)} ${target.subject}:${relation.label}(关系置信度 ${relation.confidence.toFixed(2)})`));
636
+ if (connections.some(item => item.relation.kind === 'conflicts_with'))
637
+ lines.push('其中存在尚未确认的冲突:不得自行选择一方或把两条合并成事实;仅在与当前问题相关时指出不一致并请用户确认。');
638
+ return lines.join('\n');
639
+ }
640
+ function memoryRelationLabel(kind) {
641
+ return ({ supports: '支持', depends_on: '依赖', about: '关于', conflicts_with: '冲突', follows: '后续' })[kind];
642
+ }
643
+ function renderProfile(profile, update = false) {
644
+ const heading = update
645
+ ? '联系人画像已发生变化。以下快照替代本会话中更早的人物画像;被删除或纠正的旧理解不得继续使用。'
646
+ : '以下是伙伴依据长期对话形成的联系人画像基线。它用于理解用户背景和保持交流连续性,不是需要逐条复述给用户的资料。';
647
+ if (profile.entries.length === 0)
648
+ return `${heading}\n当前没有达到可靠标准的人物画像;不要沿用旧画像或自行推断。`;
649
+ return [
650
+ heading,
651
+ '仅把明确内容作为事实;当前消息与画像冲突时以用户最新陈述为准。不得据此推断未写出的性格、隐私或敏感属性。',
652
+ ...profile.entries.map(entry => `- ${entry.subject}:${auditText(entry.content, 240)}${entry.locked ? '(用户已确认)' : ''}`),
653
+ ].join('\n');
654
+ }
655
+ function renderDeferredMentions(entries) {
656
+ return [
657
+ '伙伴之前发现了与用户当前消息相关的新变化。若自然且确实有帮助,可以在本次回答末尾顺手提一句;不要抢占当前问题,也不要声称刚刚搜索。',
658
+ ...entries.map(item => `- ${item.event}${item.evidence ? `(依据:${item.evidence})` : ''}`),
432
659
  ].join('\n');
433
660
  }
434
661
  export function resolveAgentOptions(defaultModel, companion) {
@@ -452,7 +679,327 @@ export function renewedSession(previous, now = Date.now()) {
452
679
  export function partnerCwd(root, companionId) {
453
680
  return join(root, 'partners', companionId);
454
681
  }
455
- function renderPersona(companion) {
682
+ async function createHeartbeatScope(ctx, conversation, concerns) {
683
+ const parent = scopeOf(conversation.ctx);
684
+ if (parent === undefined)
685
+ throw new Error('伙伴心跳无法解析当前 Agent 作用域');
686
+ const availableTools = [...HEARTBEAT_TOOLS].filter(name => ctx.tools.get(name, conversation) !== undefined);
687
+ const policy = heartbeatToolPolicy(concerns, availableTools);
688
+ const allowedTools = [...policy.allowed];
689
+ if (allowedTools.length === 0)
690
+ throw new Error('伙伴心跳没有可用的只读发现工具');
691
+ const cwd = conversation.session.header.cwd;
692
+ if (!cwd)
693
+ throw new Error('伙伴心跳缺少当前伙伴工作目录');
694
+ const fileAccess = await resolveHeartbeatFileAccess(cwd, concerns);
695
+ const agent = {};
696
+ const scope = createScope(ctx, agent, { parent });
697
+ const agentCtx = scope.ctx.extend({ agent });
698
+ Object.assign(agent, {
699
+ id: conversation.id,
700
+ options: conversation.options,
701
+ session: conversation.session,
702
+ inbox: conversation.inbox,
703
+ status: 'idle',
704
+ ctx: agentCtx,
705
+ cancel: () => { },
706
+ whenIdle: async () => { },
707
+ runMaintenance: async (job) => job(new AbortController().signal),
708
+ send: () => { throw new Error('心跳作用域不接受会话消息'); },
709
+ followup: () => { throw new Error('心跳作用域不接受会话消息'); },
710
+ steer: () => { throw new Error('心跳作用域不接受会话消息'); },
711
+ inject: () => { throw new Error('心跳作用域不接受会话消息'); },
712
+ });
713
+ agentCtx.tools.presentAs('native');
714
+ agentCtx.tools.restrict({ allow: allowedTools });
715
+ agentCtx.tools.guard(exec => {
716
+ if (!policy.allowed.has(exec.name))
717
+ return `当前挂念的来源策略不允许调用 ${exec.name}`;
718
+ return heartbeatToolDenial(exec.name, exec.arguments, fileAccess);
719
+ });
720
+ return { agent, dispose: () => scope.dispose() };
721
+ }
722
+ export function heartbeatToolPolicy(concerns, available) {
723
+ const installed = new Set(available);
724
+ const hasLinkedFile = concerns.some(item => item.resources?.some(resource => resource.kind === 'file') === true);
725
+ return {
726
+ allowed: new Set([...installed].filter(name => HEARTBEAT_READ_ONLY_TOOLS.has(name) || (hasLinkedFile && HEARTBEAT_FILE_UPDATE_TOOLS.has(name)))),
727
+ };
728
+ }
729
+ async function runHeartbeatInference(ctx, agent, companion, concerns, signal) {
730
+ const startedAt = Date.now();
731
+ const traces = [];
732
+ const discoveryDeadline = startedAt + HEARTBEAT_DISCOVERY_BUDGET_MS;
733
+ try {
734
+ const selection = resolveAgentOptions(ctx.agentDefaultModel, companion);
735
+ if (!selection.provider || !selection.model)
736
+ throw new Error('伙伴心跳没有可用的模型路由');
737
+ const tools = ctx.tools.schemas(agent).filter(tool => HEARTBEAT_TOOLS.has(tool.name));
738
+ if (tools.length === 0)
739
+ throw new Error('伙伴心跳没有可用的只读发现工具');
740
+ const messages = [createUserMessage({
741
+ content: [{ type: 'text', text: concernObservationPrompt(concerns, agent.session.header.cwd) }],
742
+ source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴观察挂念变化' },
743
+ })];
744
+ for (;;) {
745
+ const discoveryOpen = Date.now() < discoveryDeadline;
746
+ const prepared = await ctx.llm.prepareCall(selection, signal);
747
+ const assembler = new BlockAssembler();
748
+ for await (const chunk of prepared.stream({
749
+ ...prepared.config,
750
+ messages,
751
+ system: renderPersona(companion, 'heartbeat'),
752
+ tools: discoveryOpen ? tools : [],
753
+ signal,
754
+ }))
755
+ assembler.push(chunk);
756
+ const finish = assembler.finish;
757
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
758
+ throw new Error(`伙伴心跳模型调用失败:${finish.failure.message}`);
759
+ }
760
+ const response = assembler.message({ kind: 'model', provider: prepared.config.provider, model: prepared.config.model });
761
+ messages.push(response);
762
+ const pending = response.content.filter((block) => block.type === 'tool-call');
763
+ if (pending.length === 0) {
764
+ const output = textContent(response.content);
765
+ return {
766
+ concerns,
767
+ candidates: parseConcernObservations(output, new Set(concerns.map(item => item.id))),
768
+ startedAt,
769
+ completedAt: Date.now(),
770
+ output,
771
+ tools: traces,
772
+ };
773
+ }
774
+ for (const call of pending) {
775
+ const argumentsValue = boundHeartbeatToolArguments(call.name, parseHeartbeatToolArguments(call));
776
+ const toolStartedAt = Date.now();
777
+ const remainingDiscoveryMs = discoveryDeadline - toolStartedAt;
778
+ const result = remainingDiscoveryMs <= 0
779
+ ? { isError: true, content: [{ type: 'text', text: '本轮发现阶段的时间预算已用完,请根据已有结果立即输出最终 Observation JSON。' }] }
780
+ : await executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs);
781
+ const toolCompletedAt = Date.now();
782
+ const visibleContent = heartbeatToolContent(call.name, result.content);
783
+ traces.push({
784
+ name: call.name,
785
+ input: auditValue(argumentsValue, HEARTBEAT_AUDIT_INPUT_LIMIT),
786
+ output: auditText(textContent(visibleContent) || '工具返回了非文本结果', HEARTBEAT_AUDIT_OUTPUT_LIMIT),
787
+ startedAt: toolStartedAt,
788
+ completedAt: toolCompletedAt,
789
+ status: result.isError ? 'failed' : 'completed',
790
+ });
791
+ messages.push(createToolResultMessage({ callId: call.id, content: visibleContent, isError: result.isError }));
792
+ }
793
+ }
794
+ }
795
+ catch (error) {
796
+ return { concerns, candidates: [], startedAt, completedAt: Date.now(), tools: traces, error: errorMessage(error) };
797
+ }
798
+ }
799
+ export function parseConcernObservations(raw, allowedIds) {
800
+ const match = raw.trim().match(/\{[\s\S]*\}/);
801
+ if (!match)
802
+ throw new Error('伙伴心跳没有返回 Observation JSON');
803
+ const parsed = JSON.parse(match[0]);
804
+ if (!Array.isArray(parsed.observations))
805
+ throw new Error('伙伴心跳 Observation 格式无效');
806
+ const seen = new Set();
807
+ return parsed.observations.flatMap(value => {
808
+ if (!isRecord(value))
809
+ return [];
810
+ const concernId = typeof value.concernId === 'string' ? value.concernId : '';
811
+ if (!allowedIds.has(concernId) || seen.has(concernId))
812
+ return [];
813
+ seen.add(concernId);
814
+ const changed = value.changed === true;
815
+ const nextCheckInMinutes = boundedConcernCheckMinutes(value.nextCheckInMinutes);
816
+ const notificationRuleEffect = value.notificationRuleEffect === 'notify' || value.notificationRuleEffect === 'suppress'
817
+ ? value.notificationRuleEffect
818
+ : 'auto';
819
+ return [{
820
+ concernId,
821
+ changed,
822
+ event: changed && typeof value.event === 'string' ? value.event.trim().slice(0, 800) : '',
823
+ evidence: typeof value.evidence === 'string' ? value.evidence.trim().slice(0, 2_000) : '',
824
+ source: typeof value.source === 'string' ? value.source.trim().slice(0, 240) : '',
825
+ relevance: boundedScore(value.relevance),
826
+ confidence: boundedScore(value.confidence),
827
+ actionability: boundedScore(value.actionability),
828
+ ...(nextCheckInMinutes === undefined ? {} : { nextCheckInMinutes }),
829
+ notificationRuleEffect,
830
+ notificationRuleReason: typeof value.notificationRuleReason === 'string' ? value.notificationRuleReason.trim().slice(0, 800) : '',
831
+ }];
832
+ });
833
+ }
834
+ function boundedScore(value) {
835
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : .5;
836
+ }
837
+ function parseHeartbeatToolArguments(call) {
838
+ try {
839
+ return JSON.parse(call.arguments);
840
+ }
841
+ catch {
842
+ return { __invalidArguments: call.arguments };
843
+ }
844
+ }
845
+ async function resolveHeartbeatFileAccess(cwd, concerns) {
846
+ const root = await realpath(cwd);
847
+ const writable = new Set();
848
+ const locators = concerns.flatMap(item => item.resources ?? []).filter(item => item.kind === 'file').map(item => item.locator);
849
+ for (const locator of new Set(locators)) {
850
+ const lexical = resolve(root, locator);
851
+ if (!pathInside(root, lexical) || isPartnerMemoryPath(relative(root, lexical)))
852
+ continue;
853
+ try {
854
+ const actual = await realpath(lexical);
855
+ const info = await stat(actual);
856
+ if (!info.isFile() || !pathInside(root, actual) || isPartnerMemoryPath(relative(root, actual)))
857
+ continue;
858
+ writable.add(lexical);
859
+ writable.add(actual);
860
+ }
861
+ catch { /* References may become stale; stale files remain read-only and cannot be recreated. */ }
862
+ }
863
+ return { root, writable };
864
+ }
865
+ export function heartbeatToolDenial(name, argumentsValue, access) {
866
+ if (!isRecord(argumentsValue))
867
+ return undefined;
868
+ const isRead = ['glob', 'grep', 'read'].includes(name);
869
+ const isUpdate = HEARTBEAT_FILE_UPDATE_TOOLS.has(name);
870
+ if (!isRead && !isUpdate)
871
+ return undefined;
872
+ const paths = [argumentsValue.path, argumentsValue.file_path].filter((value) => typeof value === 'string');
873
+ if (paths.some(isPartnerMemoryPath))
874
+ return '伙伴心跳不能访问长期记忆、挂念数据库、会话归档或其备份目录;请改查普通工作文件或其他安全来源。';
875
+ if (access && paths.some(value => !pathInside(access.root, resolve(access.root, value))))
876
+ return '伙伴心跳只能访问当前伙伴工作目录内的文件。';
877
+ if (!isUpdate)
878
+ return undefined;
879
+ if (name === 'str_replace_editor' && argumentsValue.command === 'create')
880
+ return '心跳只能更新已经关联且现存的 @文件,不能创建新文件。';
881
+ const target = paths[0];
882
+ if (paths.length !== 1 || target === undefined || !access || !access.writable.has(resolve(access.root, target))) {
883
+ return '心跳只能更新当前挂念明确关联的现存 @文件,不能修改其他文件。';
884
+ }
885
+ return undefined;
886
+ }
887
+ async function executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs) {
888
+ const configuredTimeoutMs = call.name === 'web_fetch' || call.name === 'web_source' ? HEARTBEAT_WEB_TOOL_TIMEOUT_MS : HEARTBEAT_TOOL_TIMEOUT_MS;
889
+ const timeoutMs = Math.max(1, Math.min(configuredTimeoutMs, remainingDiscoveryMs));
890
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
891
+ const toolSignal = AbortSignal.any([signal, timeoutSignal]);
892
+ try {
893
+ return await ctx.tools.execute({ callId: call.id, name: call.name, arguments: argumentsValue, agent, signal: toolSignal });
894
+ }
895
+ catch (error) {
896
+ const message = timeoutSignal.aborted && !signal.aborted
897
+ ? `工具 ${call.name} 超过 ${timeoutMs / 1_000} 秒,已跳过;请按既有规则判断来源不可用,或根据已有结果收束。`
898
+ : `工具 ${call.name} 调用失败:${errorMessage(error)}`;
899
+ return { isError: true, content: [{ type: 'text', text: message }] };
900
+ }
901
+ }
902
+ function pathInside(root, target) {
903
+ const value = relative(root, target);
904
+ return value === '' || (value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value));
905
+ }
906
+ function boundHeartbeatToolArguments(name, value) {
907
+ if (!isRecord(value))
908
+ return value;
909
+ const bounded = { ...value };
910
+ if (name === 'knowledge_base_search' || name === 'knowledge_search')
911
+ bounded.limit = boundedInteger(value.limit, 4);
912
+ if (name === 'knowledge_read')
913
+ bounded.maxChars = boundedInteger(value.maxChars, HEARTBEAT_KNOWLEDGE_RESULT_LIMIT);
914
+ if (name === 'web_source')
915
+ bounded.max_chars = boundedInteger(value.max_chars, 120_000);
916
+ if (name === 'read')
917
+ bounded.limit = boundedInteger(value.limit, 80);
918
+ return bounded;
919
+ }
920
+ function heartbeatToolContent(name, content) {
921
+ let remaining = heartbeatToolResultLimit(name);
922
+ const bounded = [];
923
+ for (const block of content) {
924
+ if (remaining <= 0)
925
+ break;
926
+ if (block.type !== 'text') {
927
+ bounded.push(block);
928
+ continue;
929
+ }
930
+ const sanitized = name === 'glob'
931
+ ? block.text.split('\n').filter(line => !isPartnerMemoryPath(line.trim())).join('\n')
932
+ : block.text;
933
+ const text = sanitized.length <= remaining ? sanitized : `${sanitized.slice(0, Math.max(0, remaining - 1))}…`;
934
+ if (text)
935
+ bounded.push({ ...block, text });
936
+ remaining -= text.length;
937
+ }
938
+ if (bounded.length === 0 && name === 'glob')
939
+ return [{ type: 'text', text: '未发现可供心跳检查的普通工作文件;伙伴记忆与会话归档已从结果中排除。' }];
940
+ return bounded;
941
+ }
942
+ function heartbeatToolResultLimit(name) {
943
+ if (name === 'knowledge_read')
944
+ return HEARTBEAT_KNOWLEDGE_RESULT_LIMIT;
945
+ if (name === 'web_fetch')
946
+ return HEARTBEAT_WEB_FETCH_RESULT_LIMIT;
947
+ if (name === 'web_source')
948
+ return HEARTBEAT_WEB_SOURCE_RESULT_LIMIT;
949
+ return HEARTBEAT_TOOL_RESULT_LIMIT;
950
+ }
951
+ function isPartnerMemoryPath(value) {
952
+ const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '/');
953
+ return /(?:^|\/)(?:memory(?:\/|-backup(?:-|\/)|$)|concerns(?:\/|$))/i.test(normalized);
954
+ }
955
+ function boundedInteger(value, maximum) {
956
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : maximum;
957
+ }
958
+ function isRecord(value) {
959
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
960
+ }
961
+ export function renderHeartbeatActivity(execution, outcome, deliveryError) {
962
+ const status = outcome === 'notified' ? '已通过微信提醒' : outcome === 'quiet' ? '无需主动提醒' : '执行失败';
963
+ const lines = [
964
+ `状态:${status}`,
965
+ `本轮挂念:${execution.concerns.map(item => item.subject).join(';')}`,
966
+ `用时:${formatDuration(execution.completedAt - execution.startedAt)}`,
967
+ `工具:${execution.tools.length > 0 ? `${execution.tools.length} 次调用` : '未调用'}`,
968
+ ];
969
+ execution.tools.forEach((trace, index) => {
970
+ lines.push('', `${index + 1}. ${trace.name} · ${trace.status === 'completed' ? '完成' : '失败'} · ${formatDuration(trace.completedAt - trace.startedAt)}`, `输入:${trace.input}`, `结果:${trace.output}`);
971
+ });
972
+ const changed = execution.observations ?? [];
973
+ const conclusion = execution.error || deliveryError
974
+ ? `失败原因:${deliveryError ?? execution.error}`
975
+ : changed.length > 0
976
+ ? `新变化:\n${changed.map(item => `- ${item.event}(${decisionLabel(item.decision)} · ${Math.round(item.interruptScore * 100)})`).join('\n')}`
977
+ : '最终结论:本轮没有发现经过校验的新变化。';
978
+ lines.push('', conclusion);
979
+ return lines.join('\n');
980
+ }
981
+ function decisionLabel(value) {
982
+ return value === 'notify' ? '已提醒' : value === 'feed' ? '伙伴动态' : value === 'defer' ? '顺手一提' : value === 'remember' ? '静默记下' : '忽略';
983
+ }
984
+ function auditValue(value, limit) {
985
+ try {
986
+ return auditText(JSON.stringify(value), limit);
987
+ }
988
+ catch {
989
+ return auditText(String(value), limit);
990
+ }
991
+ }
992
+ function auditText(value, limit) {
993
+ const text = value.trim().replace(/\n{3,}/g, '\n\n');
994
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
995
+ }
996
+ function formatDuration(milliseconds) {
997
+ if (milliseconds < 1_000)
998
+ return `${Math.max(0, milliseconds)} ms`;
999
+ return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 1 : 0)} 秒`;
1000
+ }
1001
+ function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
1002
+ function renderPersona(companion, surface = 'conversation') {
456
1003
  const capabilities = companion.capabilities.length > 0 ? companion.capabilities.join('、') : '由当前 Agent Preset 提供的基础能力';
457
1004
  return [
458
1005
  `你当前以长期工作伙伴「${companion.name}」的身份工作。`,
@@ -460,7 +1007,9 @@ function renderPersona(companion) {
460
1007
  companion.description ? `定位:${companion.description}` : '',
461
1008
  companion.instructions ? `长期行为准则:\n${companion.instructions}` : '',
462
1009
  `用户为此伙伴启用的能力范围:${capabilities}。能力标识不等于授权;只能调用当前会话实际提供且已经通过权限校验的工具。`,
463
- '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
1010
+ surface === 'heartbeat'
1011
+ ? '这是一轮独立的伙伴心跳,不是用户聊天的延续。只根据本轮真实可读信息行动,最终结果由渠道适配器决定是否发送。'
1012
+ : '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
464
1013
  ].filter(Boolean).join('\n\n');
465
1014
  }
466
1015
  export function renderToolProtocol() {