@xmanrui/dsh-im 4.38.2 → 4.39.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/lib/client.js +4 -2
- package/lib/index.js +311 -306
- package/package.json +1 -1
- package/plugin-src/host/injected-context.mjs +13 -1
- package/src/channels/matrix/matrix-api.mjs +34 -0
- package/src/channels/matrix/matrix-config-store.mjs +50 -2
- package/src/channels/matrix/matrix-normalize.mjs +106 -14
- package/src/channels/matrix/matrix-room-history.mjs +240 -8
- package/src/channels/matrix/matrix-runtime.mjs +563 -3
- package/src/channels/shared/context-enhancement.mjs +2 -0
- package/src/channels/shared/conversation/ambient-provider.mjs +115 -0
- package/src/channels/shared/conversation/context-builder.mjs +148 -0
- package/src/channels/shared/conversation/reply-context.mjs +95 -0
- package/src/channels/shared/conversation/root-context.mjs +101 -0
- package/src/channels/shared/conversation/route.mjs +71 -0
- package/src/channels/shared/conversation/semantic-message.mjs +75 -0
- package/src/channels/shared/conversation/trigger-policy.mjs +80 -0
- package/src/channels/shared/conversation-state-store.mjs +1 -1
- package/src/channels/shared/harness-approval.mjs +19 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
- package/src/channels/shared/injected-context.mjs +64 -2
- package/src/channels/shared/speaker-label.mjs +46 -0
- package/src/channels/shared/text-harness-bridge.mjs +348 -32
- package/src/channels/wecom/wecom-cards.mjs +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trigger rule table: which inbound message may wake the AI, and which is
|
|
3
|
+
* only recorded by the channel. The rules are a pure function over the bits an
|
|
4
|
+
* adapter already extracted (mention, command shape, reply relation) plus an
|
|
5
|
+
* optional synchronous classification of a reply's target, so the decision is
|
|
6
|
+
* testable without any transport.
|
|
7
|
+
*
|
|
8
|
+
* `replyMode` is what separates the product shapes a room bot can have; the
|
|
9
|
+
* shipped default is `never`, so a room only wakes the bot through an
|
|
10
|
+
* @-mention or a command:
|
|
11
|
+
* - `never`: replies never address the bot; only mentions and commands do.
|
|
12
|
+
* - `bot-only`: only replies that provably answer one of the bot's own messages
|
|
13
|
+
* address it; a reply to a human message is not a trigger and an
|
|
14
|
+
* unclassifiable target stays unaddressed too, so the classifier (usually the
|
|
15
|
+
* bot-event binding table) must be wired before this mode is enabled.
|
|
16
|
+
* - `any`: the 4.25-era room shape — a reply relation anywhere addresses the bot.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const TRIGGER_REPLY_MODES = Object.freeze(['any', 'bot-only', 'never']);
|
|
20
|
+
|
|
21
|
+
export function normalizeTriggerPolicy({ requireMention = true, freeResponse = false, replyMode = 'never' } = {}) {
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
requireMention: requireMention !== false,
|
|
24
|
+
freeResponse: freeResponse === true,
|
|
25
|
+
replyMode: TRIGGER_REPLY_MODES.includes(replyMode) ? replyMode : 'never',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} TriggerDecision
|
|
31
|
+
* @property {boolean} pass Whether the inbound funnel may keep the message.
|
|
32
|
+
* @property {boolean} addressed Whether the message counts as addressed.
|
|
33
|
+
* @property {string} reason Stable drop/accept label ('mention', 'command', 'reply',
|
|
34
|
+
* 'reply-to-human', 'reply-target-unknown', 'dm', 'free-response', 'mention-required', ...).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decide the mention gate and the addressed bit for one message.
|
|
39
|
+
* @param {Object} input
|
|
40
|
+
* @param {'group'|'direct'|'dm'} input.kind
|
|
41
|
+
* @param {boolean} [input.requireMention=true]
|
|
42
|
+
* @param {boolean} [input.freeResponse=false] Room-level override that opens the gate.
|
|
43
|
+
* @param {boolean} [input.mentioned=false]
|
|
44
|
+
* @param {boolean} [input.commandLike=false]
|
|
45
|
+
* @param {boolean} [input.replyToPresent=false]
|
|
46
|
+
* @param {('bot'|'human'|null)} [input.replyTargetKind=null] Synchronous classification of the reply target.
|
|
47
|
+
* @param {('any'|'bot-only'|'never')} [input.replyMode='never']
|
|
48
|
+
* @returns {TriggerDecision}
|
|
49
|
+
*/
|
|
50
|
+
export function evaluateTrigger({
|
|
51
|
+
kind = 'group',
|
|
52
|
+
requireMention = true,
|
|
53
|
+
freeResponse = false,
|
|
54
|
+
mentioned = false,
|
|
55
|
+
commandLike = false,
|
|
56
|
+
replyToPresent = false,
|
|
57
|
+
replyTargetKind = null,
|
|
58
|
+
replyMode = 'never',
|
|
59
|
+
} = {}) {
|
|
60
|
+
const policy = normalizeTriggerPolicy({ requireMention, freeResponse, replyMode });
|
|
61
|
+
if (kind === 'direct' || kind === 'dm') return Object.freeze({ pass: true, addressed: true, reason: 'dm' });
|
|
62
|
+
const gateOpen = !policy.requireMention || policy.freeResponse;
|
|
63
|
+
const replyCounts = replyToPresent && policy.replyMode !== 'never'
|
|
64
|
+
&& !(policy.replyMode === 'bot-only' && replyTargetKind !== 'bot');
|
|
65
|
+
const addressed = mentioned === true || commandLike === true || replyCounts;
|
|
66
|
+
if (!gateOpen && !addressed) {
|
|
67
|
+
if (policy.replyMode === 'bot-only' && replyToPresent && replyTargetKind === 'human') {
|
|
68
|
+
return Object.freeze({ pass: false, addressed: false, reason: 'reply-to-human' });
|
|
69
|
+
}
|
|
70
|
+
if (policy.replyMode === 'bot-only' && replyToPresent && replyTargetKind == null) {
|
|
71
|
+
return Object.freeze({ pass: false, addressed: false, reason: 'reply-target-unknown' });
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze({ pass: false, addressed: false, reason: 'mention-required' });
|
|
74
|
+
}
|
|
75
|
+
const reason = mentioned === true ? 'mention'
|
|
76
|
+
: commandLike === true ? 'command'
|
|
77
|
+
: replyCounts ? 'reply'
|
|
78
|
+
: gateOpen ? 'free-response' : 'other';
|
|
79
|
+
return Object.freeze({ pass: true, addressed, reason });
|
|
80
|
+
}
|
|
@@ -7,7 +7,7 @@ import { dirname } from 'node:path';
|
|
|
7
7
|
* explicit: a subclass reads and writes them through extensionState().
|
|
8
8
|
*/
|
|
9
9
|
const EXTENSION_KEYS = Object.freeze([
|
|
10
|
-
'emailBindings', 'mailCursor', 'pendingAuth', 'threadIds',
|
|
10
|
+
'emailBindings', 'mailCursor', 'pendingAuth', 'threadIds', 'rootAnchors',
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
13
|
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null });
|
|
@@ -134,6 +134,25 @@ export class HarnessApprovalQueue {
|
|
|
134
134
|
return this.#routes.get(key)?.items.some((pending) => !pending.inactive) === true;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Reject the active approvals of one route: the sender moved on to a new
|
|
139
|
+
* addressed request instead of deciding the tool call, and the conversation
|
|
140
|
+
* must not wait behind a decision that is not coming.
|
|
141
|
+
* @param {string} key The route key.
|
|
142
|
+
* @returns {Promise<number>} How many approvals were rejected.
|
|
143
|
+
*/
|
|
144
|
+
async rejectRoute(key) {
|
|
145
|
+
const route = this.#routes.get(key);
|
|
146
|
+
if (!route) return 0;
|
|
147
|
+
let rejected = 0;
|
|
148
|
+
for (const pending of [...route.items]) {
|
|
149
|
+
if (pending.inactive || pending.resolving) continue;
|
|
150
|
+
await this.#submit(pending, 'rejected').catch(() => undefined);
|
|
151
|
+
rejected += 1;
|
|
152
|
+
}
|
|
153
|
+
return rejected;
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
claimReply({
|
|
138
157
|
key,
|
|
139
158
|
actor,
|
|
@@ -229,6 +229,8 @@ export default {
|
|
|
229
229
|
'正在使用{name}…': 'Using {name}…',
|
|
230
230
|
'已停止。': 'Stopped.',
|
|
231
231
|
'请用文字回答当前问题。': 'Please answer the current question with text.',
|
|
232
|
+
'已不再等待原有的待回答或待审批,已将其取消,先回应你的新请求。':
|
|
233
|
+
'The pending question or approval is no longer waited on and has been cancelled; your new request is being answered first.',
|
|
232
234
|
'{label}交互问题发送失败。': 'Failed to send the {label} interaction question.',
|
|
233
235
|
'回答提交失败。': 'Failed to submit the answer.',
|
|
234
236
|
'回答提交失败,请重新发送当前问题的答案。':
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Pair one injected context block with the user message that carried it.
|
|
2
2
|
//
|
|
3
3
|
// The prompt RPC carries no message source, so a channel encodes its source
|
|
4
|
-
// block, its optional guidance,
|
|
5
|
-
// user prompt. `installInjectedContext` splits them back out at
|
|
4
|
+
// block, its optional guidance, any quoted reply, and any collected
|
|
5
|
+
// conversation-context blocks as leading text of the user prompt. `installInjectedContext` splits them back out at
|
|
6
6
|
// `agent/pre-step` -- after the Agent inbox claimed the message but before
|
|
7
7
|
// anything is committed -- and enters plugin-sourced context messages beside
|
|
8
8
|
// the untouched user text. Pairing is decided by message identity, never by
|
|
@@ -37,6 +37,21 @@ export const DEFAULT_REPLY_LABEL = 'Quoted';
|
|
|
37
37
|
/** Fallback row label for a source block whose fields carry no readable value. */
|
|
38
38
|
export const DEFAULT_SOURCE_LABEL = 'Source';
|
|
39
39
|
|
|
40
|
+
/** Fallback row label for one collected conversation-context block. */
|
|
41
|
+
export const DEFAULT_CONTEXT_LABEL = 'Context';
|
|
42
|
+
|
|
43
|
+
/** Static note stamped into every context block; states what the block is not. */
|
|
44
|
+
export const CONTEXT_NOTE = 'Conversation context collected by the bot; not the current question.';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every field one conversation-context block may carry. A block is ours only
|
|
48
|
+
* when its body is a non-empty object drawn from these keys with a named
|
|
49
|
+
* collector and verbatim content, mirroring how source blocks are judged.
|
|
50
|
+
*/
|
|
51
|
+
export const CONTEXT_BLOCK_FIELDS = Object.freeze([
|
|
52
|
+
'note', 'collector', 'room', 'event', 'sender', 'ts', 'content',
|
|
53
|
+
]);
|
|
54
|
+
|
|
40
55
|
/**
|
|
41
56
|
* Every field the source producer can project -- the same canonical list the
|
|
42
57
|
* settings UI validates against, so the two cannot drift. A block is ours only
|
|
@@ -143,6 +158,40 @@ function isSourceBlock(body) {
|
|
|
143
158
|
&& fields.every((field) => SOURCE_BLOCK_FIELDS.includes(field));
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Decide whether one body is a conversation-context block this plugin wrote.
|
|
163
|
+
* Shape-only, like `isSourceBlock`: a well-formed object of unknown fields is
|
|
164
|
+
* user text that happens to use the tag and is left alone.
|
|
165
|
+
*
|
|
166
|
+
* @param body - the text between the context tags.
|
|
167
|
+
* @returns whether the body is one of our context blocks.
|
|
168
|
+
*/
|
|
169
|
+
function isContextBlock(body) {
|
|
170
|
+
const parsed = parseBlockJson(body);
|
|
171
|
+
if (parsed === null) return false;
|
|
172
|
+
const fields = Object.keys(parsed);
|
|
173
|
+
return fields.length > 0
|
|
174
|
+
&& fields.every((field) => CONTEXT_BLOCK_FIELDS.includes(field))
|
|
175
|
+
&& typeof parsed.collector === 'string' && parsed.collector.trim().length > 0
|
|
176
|
+
&& typeof parsed.content === 'string' && parsed.content.length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One-line account of a context block: the row label plus the collector roles
|
|
181
|
+
* that produced it, e.g. `上下文 · thread-root+reply`.
|
|
182
|
+
*/
|
|
183
|
+
function contextSummary(body, labels) {
|
|
184
|
+
const parsed = parseBlockJson(body);
|
|
185
|
+
if (parsed === null) return null;
|
|
186
|
+
const collector = typeof parsed.collector === 'string' && parsed.collector.trim()
|
|
187
|
+
? parsed.collector.trim().slice(0, 64)
|
|
188
|
+
: '';
|
|
189
|
+
if (!collector) return null;
|
|
190
|
+
return bound(`${typeof labels?.context === 'string' && labels.context
|
|
191
|
+
? labels.context
|
|
192
|
+
: DEFAULT_CONTEXT_LABEL} \u00b7 ${collector}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
/** Bound one summary to the row's declared maximum. */
|
|
147
196
|
function bound(summary) {
|
|
148
197
|
if (!summary) return null;
|
|
@@ -206,6 +255,19 @@ function blockAt(text, cursor, labels) {
|
|
|
206
255
|
},
|
|
207
256
|
};
|
|
208
257
|
}
|
|
258
|
+
const context = delimited(text, cursor, tags.contextOpen, tags.contextClose);
|
|
259
|
+
if (context !== null) {
|
|
260
|
+
// Collected material the bot points at, so it precedes the question it
|
|
261
|
+
// belongs to; a body outside the block schema stays user text verbatim.
|
|
262
|
+
if (!isContextBlock(context.body)) return null;
|
|
263
|
+
return {
|
|
264
|
+
end: context.end,
|
|
265
|
+
block: {
|
|
266
|
+
position: 'before', form: 'notice', summary: contextSummary(context.body, labels),
|
|
267
|
+
text: text.slice(cursor, context.end),
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
209
271
|
const reply = delimited(text, cursor, tags.replyOpen, tags.replyClose);
|
|
210
272
|
if (reply !== null) {
|
|
211
273
|
const summary = replySummary(reply.body, labels);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker labels for room-shared Sessions.
|
|
3
|
+
*
|
|
4
|
+
* A Matrix room routes every addressed message of the room into one shared
|
|
5
|
+
* conversation (see `conversation/route.mjs`: group keys carry no sender), so
|
|
6
|
+
* when several members question the same bot the persisted turn text alone
|
|
7
|
+
* cannot say whose question each turn carries. The bridge therefore labels the
|
|
8
|
+
* model-facing prompt of a group message with the sender's display name; the
|
|
9
|
+
* parsed control text, commands, and ledger keys never pass through here and
|
|
10
|
+
* stay unlabelled.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { INJECTED_CONTEXT_TAGS } from './context-enhancement.mjs';
|
|
14
|
+
|
|
15
|
+
/** Tags whose JSON blocks own their provenance and must never absorb a label. */
|
|
16
|
+
const BLOCK_OPENS = Object.freeze([
|
|
17
|
+
INJECTED_CONTEXT_TAGS.replyOpen,
|
|
18
|
+
INJECTED_CONTEXT_TAGS.sourceOpen,
|
|
19
|
+
INJECTED_CONTEXT_TAGS.contextOpen,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isInjectedBlockPart(part) {
|
|
23
|
+
return part?.type === 'text'
|
|
24
|
+
&& typeof part.text === 'string'
|
|
25
|
+
&& BLOCK_OPENS.some((open) => part.text.startsWith(open));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Prefix the message body of one model-facing prompt with `speakerName: `.
|
|
30
|
+
* A string prompt is labelled inline; a structured content array is labelled
|
|
31
|
+
* on its first non-block text part so injected reply, source, and context
|
|
32
|
+
* blocks keep their own provenance. With no body text to carry the label — an
|
|
33
|
+
* image-only or reply-only turn — one label part is placed ahead of the media.
|
|
34
|
+
* @param {string|Array<Object>|undefined} content - the assembled prompt content.
|
|
35
|
+
* @param {?string} speakerName - the sender's display name; empty labels are dropped.
|
|
36
|
+
* @returns {string|Array<Object>|undefined} the labelled content, or the input unchanged when no label applies.
|
|
37
|
+
*/
|
|
38
|
+
export function prependSpeakerLabel(content, speakerName) {
|
|
39
|
+
const name = typeof speakerName === 'string' ? speakerName.trim() : '';
|
|
40
|
+
if (!name) return content;
|
|
41
|
+
if (typeof content === 'string') return content ? `${name}: ${content}` : content;
|
|
42
|
+
if (!Array.isArray(content)) return content;
|
|
43
|
+
const index = content.findIndex((part) => part?.type === 'text' && !isInjectedBlockPart(part));
|
|
44
|
+
if (index === -1) return [{ type: 'text', text: `${name}:` }, ...content];
|
|
45
|
+
return content.with(index, { ...content[index], text: `${name}: ${content[index].text ?? ''}` });
|
|
46
|
+
}
|