@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
|
@@ -16,6 +16,8 @@ export const INJECTED_CONTEXT_TAGS = Object.freeze({
|
|
|
16
16
|
guidanceClose: '</dsh_im_source_guidance>',
|
|
17
17
|
replyOpen: '<dsh_im_reply_to>',
|
|
18
18
|
replyClose: '</dsh_im_reply_to>',
|
|
19
|
+
contextOpen: '<dsh_im_context>',
|
|
20
|
+
contextClose: '</dsh_im_context>',
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
/** Separator the producer joins prefix blocks with, and the splitter consumes. */
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient context: the inferred-relation half of the context engine.
|
|
3
|
+
*
|
|
4
|
+
* Explicit relations (reply, thread root, bot-event binding) never need this
|
|
5
|
+
* module; it exists for the case where nobody replied, nobody opened a thread,
|
|
6
|
+
* yet a human reading the room obviously groups the messages anyway — a sender
|
|
7
|
+
* firing a burst of short messages right before addressing the bot. The
|
|
8
|
+
* provider reads the channel's bounded room history record (no second store),
|
|
9
|
+
* and a burst is a trailing same-sender run whose consecutive entries stay
|
|
10
|
+
* within `burstGapMs`; anything older or broken by the gap falls back to the
|
|
11
|
+
* bounded recent window. Entries the record already marked as injected are
|
|
12
|
+
* never re-offered, so ambient context is consumed at most once per entry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function boundedInt(value, min, max, fallback) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value <= 0) return fallback;
|
|
17
|
+
return Math.min(Math.max(value, min), max);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_AMBIENT_BURST_GAP_MS = 90_000;
|
|
21
|
+
export const DEFAULT_AMBIENT_WINDOW_MS = 180_000;
|
|
22
|
+
export const DEFAULT_AMBIENT_RECENT_COUNT = 20;
|
|
23
|
+
export const DEFAULT_AMBIENT_MAX_CONTEXT_CHARS = 8_000;
|
|
24
|
+
|
|
25
|
+
export class WindowAmbientProvider {
|
|
26
|
+
#store;
|
|
27
|
+
#burstGapMs;
|
|
28
|
+
#windowMs;
|
|
29
|
+
#recentCount;
|
|
30
|
+
#maxContextChars;
|
|
31
|
+
|
|
32
|
+
constructor({
|
|
33
|
+
store,
|
|
34
|
+
burstGapMs = DEFAULT_AMBIENT_BURST_GAP_MS,
|
|
35
|
+
recentWindowMs = DEFAULT_AMBIENT_WINDOW_MS,
|
|
36
|
+
recentCount = DEFAULT_AMBIENT_RECENT_COUNT,
|
|
37
|
+
maxContextChars = DEFAULT_AMBIENT_MAX_CONTEXT_CHARS,
|
|
38
|
+
} = {}) {
|
|
39
|
+
this.#store = store && typeof store.search === 'function' ? store : null;
|
|
40
|
+
this.#burstGapMs = boundedInt(burstGapMs, 5_000, 3_600_000, DEFAULT_AMBIENT_BURST_GAP_MS);
|
|
41
|
+
this.#windowMs = boundedInt(recentWindowMs, 10_000, 86_400_000, DEFAULT_AMBIENT_WINDOW_MS);
|
|
42
|
+
this.#recentCount = boundedInt(recentCount, 1, 200, DEFAULT_AMBIENT_RECENT_COUNT);
|
|
43
|
+
this.#maxContextChars = boundedInt(maxContextChars, 200, 200_000, DEFAULT_AMBIENT_MAX_CONTEXT_CHARS);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collect the ambient entries that may accompany one triggered message.
|
|
48
|
+
* @param {{roomId: string, senderId: string, threadId?: string|null, now?: number}} query
|
|
49
|
+
* — the optional `threadId` keeps the window inside one message list: the
|
|
50
|
+
* room's mainline (`null`) or the named thread, matching what members read
|
|
51
|
+
* in the current conversation.
|
|
52
|
+
* @returns {{burst: object[], recent: object[]}} both chronological; already-consumed and
|
|
53
|
+
* non-human entries are excluded, and the recent list never repeats a burst entry.
|
|
54
|
+
*/
|
|
55
|
+
query({ roomId, senderId, threadId = null, now = Date.now() } = {}) {
|
|
56
|
+
if (!this.#store || !roomId || typeof senderId !== 'string' || !senderId) {
|
|
57
|
+
return { burst: [], recent: [] };
|
|
58
|
+
}
|
|
59
|
+
const wantedThread = typeof threadId === 'string' && threadId ? threadId : null;
|
|
60
|
+
const span = Math.max(this.#windowMs, this.#burstGapMs);
|
|
61
|
+
// The record answers newest-first; one extra slice covers the burst run on
|
|
62
|
+
// top of the recent budget before anything is filtered out.
|
|
63
|
+
const fetched = this.#store.search({
|
|
64
|
+
roomId,
|
|
65
|
+
from: now - span,
|
|
66
|
+
to: now,
|
|
67
|
+
limit: Math.min(500, Math.max(this.#recentCount * 4, 60)),
|
|
68
|
+
});
|
|
69
|
+
// The record keeps arrival order, which a replayed or seeded batch can leave
|
|
70
|
+
// out of timestamp order; the gap walk and the recency budgets need true time order.
|
|
71
|
+
const chronological = [...(Array.isArray(fetched) ? fetched : [])].sort((left, right) => left.ts - right.ts);
|
|
72
|
+
// The record's own notion of un-consumed human speech is the pending list;
|
|
73
|
+
// `search` answers over all retained entries, so intersect with it instead of
|
|
74
|
+
// trusting the per-entry flag, which is only the record's rendering of it.
|
|
75
|
+
const pendingIds = typeof this.#store.pending === 'function'
|
|
76
|
+
? new Set(this.#store.pending({ roomId, limit: 5_000 }).map((entry) => entry.eventId))
|
|
77
|
+
: null;
|
|
78
|
+
const candidates = chronological.filter((entry) => entry.kind === 'human'
|
|
79
|
+
&& entry.injected !== true
|
|
80
|
+
&& (entry.threadId ?? null) === wantedThread
|
|
81
|
+
&& (pendingIds === null || pendingIds.has(entry.eventId)));
|
|
82
|
+
const burst = [];
|
|
83
|
+
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const entry = candidates[index];
|
|
85
|
+
if (entry.sender !== senderId) break;
|
|
86
|
+
const later = burst[burst.length - 1];
|
|
87
|
+
if (later && later.ts - entry.ts > this.#burstGapMs) break;
|
|
88
|
+
burst.push(entry);
|
|
89
|
+
}
|
|
90
|
+
burst.reverse();
|
|
91
|
+
const burstIds = new Set(burst.map((entry) => entry.eventId));
|
|
92
|
+
const recent = [];
|
|
93
|
+
let used = 0;
|
|
94
|
+
for (let index = candidates.length - 1; index >= 0 && recent.length < this.#recentCount; index -= 1) {
|
|
95
|
+
const entry = candidates[index];
|
|
96
|
+
if (burstIds.has(entry.eventId)) continue;
|
|
97
|
+
const cost = entry.text.length + (entry.name ? entry.name.length + 2 : 1);
|
|
98
|
+
if (used + cost > this.#maxContextChars) break;
|
|
99
|
+
used += cost;
|
|
100
|
+
recent.unshift(entry);
|
|
101
|
+
}
|
|
102
|
+
return { burst, recent };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mark the offered entries as consumed so they are not offered again.
|
|
107
|
+
* @param {{roomId: string, entries: object[]}} consumed
|
|
108
|
+
* @returns {Promise<number>} the number newly marked; 0 when the record cannot mark.
|
|
109
|
+
*/
|
|
110
|
+
async markConsumed({ roomId, entries } = {}) {
|
|
111
|
+
if (!this.#store || typeof this.#store.markInjected !== 'function') return 0;
|
|
112
|
+
if (!roomId || !Array.isArray(entries) || entries.length === 0) return 0;
|
|
113
|
+
return await this.#store.markInjected(roomId, entries.map((entry) => entry.eventId));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly of the collected context entries into injected context blocks that
|
|
3
|
+
* ride along with the triggering prompt.
|
|
4
|
+
*
|
|
5
|
+
* Each entry becomes one `<dsh_im_context>` block of the shared injected
|
|
6
|
+
* context grammar, so the Host's `agent/pre-step` listener splits every entry
|
|
7
|
+
* back out into its own plugin-sourced context message beside the untouched
|
|
8
|
+
* user text: what the human asked stays exactly what the human typed, and the
|
|
9
|
+
* collected material is separated by `source`, never spliced into the question.
|
|
10
|
+
* Entries sharing one event id merge into a single block whose `collector`
|
|
11
|
+
* names every role that hit the event (e.g. `thread-root+reply`), so the same
|
|
12
|
+
* message is never presented twice under two headings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { INJECTED_CONTEXT_SEPARATOR, INJECTED_CONTEXT_TAGS } from '../context-enhancement.mjs';
|
|
16
|
+
import { CONTEXT_NOTE } from '../injected-context.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} ContextEntry
|
|
20
|
+
* @property {'thread-root'|'reply'|'ambient-burst'|'ambient-recent'|string} collector
|
|
21
|
+
* @property {string} roomId
|
|
22
|
+
* @property {?string} eventId
|
|
23
|
+
* @property {?string} sender
|
|
24
|
+
* @property {?string} name
|
|
25
|
+
* @property {?number} ts
|
|
26
|
+
* @property {string} text
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const COLLECTOR_MAX = 64;
|
|
30
|
+
const ROOM_MAX = 256;
|
|
31
|
+
const EVENT_MAX = 256;
|
|
32
|
+
const SENDER_MAX = 300;
|
|
33
|
+
const CONTENT_MAX = 8_000;
|
|
34
|
+
|
|
35
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/gu;
|
|
36
|
+
|
|
37
|
+
function cleanField(value, limit) {
|
|
38
|
+
if (typeof value !== 'string') return undefined;
|
|
39
|
+
const cleaned = value.replace(CONTROL_CHARACTERS, '').trim();
|
|
40
|
+
if (!cleaned) return undefined;
|
|
41
|
+
return cleaned.length <= limit ? cleaned : cleaned.slice(0, limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cleanContent(value) {
|
|
45
|
+
if (typeof value !== 'string') return undefined;
|
|
46
|
+
const normalized = value.replace(/\r\n?/gu, '\n').replace(CONTROL_CHARACTERS, '');
|
|
47
|
+
if (!normalized.trim()) return undefined;
|
|
48
|
+
return normalized.length <= CONTENT_MAX ? normalized : normalized.slice(0, CONTENT_MAX);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merge entries that point at the same event into one entry, keeping the first
|
|
53
|
+
* occurrence's position, room, sender and stamp and naming every collector that
|
|
54
|
+
* referenced the event in first-seen order. Entries without an event id pass
|
|
55
|
+
* through unmerged; their collected text alone is what identifies them.
|
|
56
|
+
*
|
|
57
|
+
* @param {ContextEntry[]} entries
|
|
58
|
+
* @returns {ContextEntry[]} merged entries in assembly order
|
|
59
|
+
*/
|
|
60
|
+
export function dedupeContextEntries(entries) {
|
|
61
|
+
if (!Array.isArray(entries)) return [];
|
|
62
|
+
const merged = [];
|
|
63
|
+
const byEvent = new Map();
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const eventId = cleanField(entry?.eventId, EVENT_MAX);
|
|
66
|
+
if (eventId === undefined) {
|
|
67
|
+
merged.push({ ...entry });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const existing = byEvent.get(eventId);
|
|
71
|
+
if (existing === undefined) {
|
|
72
|
+
const copy = { ...entry, collector: cleanField(entry?.collector, COLLECTOR_MAX) ?? '' };
|
|
73
|
+
byEvent.set(eventId, copy);
|
|
74
|
+
merged.push(copy);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const collector = cleanField(entry?.collector, COLLECTOR_MAX);
|
|
78
|
+
if (collector !== undefined) {
|
|
79
|
+
existing.collector = existing.collector ? `${existing.collector}+${collector}` : collector;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return merged;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Render one entry as a well-formed injected context block. The body is a
|
|
87
|
+
* plain object drawn from the block schema with `<>&` escaped, so the closing
|
|
88
|
+
* tag cannot be forged from collected values and the Host recognizes exactly
|
|
89
|
+
* the blocks this producer wrote.
|
|
90
|
+
*
|
|
91
|
+
* @param {ContextEntry} entry
|
|
92
|
+
* @returns {string} the block, or '' when the entry carries no renderable content.
|
|
93
|
+
*/
|
|
94
|
+
export function contextBlockFor(entry) {
|
|
95
|
+
const content = cleanContent(entry?.text);
|
|
96
|
+
if (content === undefined) return '';
|
|
97
|
+
const body = {
|
|
98
|
+
note: CONTEXT_NOTE,
|
|
99
|
+
collector: cleanField(entry?.collector, COLLECTOR_MAX) ?? '?',
|
|
100
|
+
...(cleanField(entry?.roomId, ROOM_MAX) !== undefined ? { room: cleanField(entry.roomId, ROOM_MAX) } : {}),
|
|
101
|
+
...(cleanField(entry?.eventId, EVENT_MAX) !== undefined ? { event: cleanField(entry.eventId, EVENT_MAX) } : {}),
|
|
102
|
+
...(() => {
|
|
103
|
+
const sender = cleanField(entry?.sender ?? entry?.name, SENDER_MAX);
|
|
104
|
+
return sender === undefined ? {} : { sender };
|
|
105
|
+
})(),
|
|
106
|
+
...(Number.isSafeInteger(entry?.ts) && entry.ts > 0 ? { ts: entry.ts } : {}),
|
|
107
|
+
content,
|
|
108
|
+
};
|
|
109
|
+
const json = JSON.stringify(body).replace(/[<>&]/gu, (character) => ({
|
|
110
|
+
'<': '\\u003c',
|
|
111
|
+
'>': '\\u003e',
|
|
112
|
+
'&': '\\u0026',
|
|
113
|
+
})[character]);
|
|
114
|
+
return `${INJECTED_CONTEXT_TAGS.contextOpen}${json}${INJECTED_CONTEXT_TAGS.contextClose}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render the whole block set: entries merge by event id, empty entries drop
|
|
119
|
+
* out, and the surviving blocks join in the order the collectors supplied
|
|
120
|
+
* (explicit collectors before ambient). Joined with the protocol separator the
|
|
121
|
+
* Host's splitter consumes, so the user text that follows is all the human
|
|
122
|
+
* actually wrote.
|
|
123
|
+
*
|
|
124
|
+
* @param {ContextEntry[]} entries
|
|
125
|
+
* @returns {string} the joined blocks, or '' when there is nothing to say.
|
|
126
|
+
*/
|
|
127
|
+
export function renderContextEntries(entries) {
|
|
128
|
+
const blocks = [];
|
|
129
|
+
for (const entry of dedupeContextEntries(entries)) {
|
|
130
|
+
const block = contextBlockFor(entry);
|
|
131
|
+
if (block) blocks.push(block);
|
|
132
|
+
}
|
|
133
|
+
return blocks.join(INJECTED_CONTEXT_SEPARATOR);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prepend the rendered blocks to the triggering prompt text, mirroring the
|
|
138
|
+
* enhancement producer's prefix pattern exactly so the splitter consumes the
|
|
139
|
+
* blocks and leaves the question verbatim.
|
|
140
|
+
*
|
|
141
|
+
* @param {{block: string, promptText: ?string}} parts
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function composeContextPrompt({ block, promptText } = {}) {
|
|
145
|
+
const text = typeof promptText === 'string' ? promptText : '';
|
|
146
|
+
if (typeof block !== 'string' || !block) return text;
|
|
147
|
+
return `${block}\n\n${text}`;
|
|
148
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit-relation context: the reply ancestor chain.
|
|
3
|
+
*
|
|
4
|
+
* A reply is a fact the platform states, not a guess, so it never consults the
|
|
5
|
+
* ambient record: the channel injects a `fetch` that resolves one event id to
|
|
6
|
+
* its text (Matrix: CS API single-event lookup, Feishu: OpenAPI message get).
|
|
7
|
+
* The walk is depth-bounded and loop-guarded because reply relations are
|
|
8
|
+
* user-authored data; a cyclic chain terminates on the first repeat instead of
|
|
9
|
+
* spinning. The result is oldest-first so the prompt reads cause before effect.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} ReplyChainNode
|
|
14
|
+
* @property {string} eventId
|
|
15
|
+
* @property {string} sender
|
|
16
|
+
* @property {string} name Display name when the channel knows one.
|
|
17
|
+
* @property {string} text
|
|
18
|
+
* @property {?number} ts
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A tiny most-recent-used cache for single-event lookups so a burst of replies
|
|
23
|
+
* into one chain does not refetch shared ancestors. Insertion order is the LRU
|
|
24
|
+
* order; reading refreshes recency.
|
|
25
|
+
* @param {number} [capacity=256]
|
|
26
|
+
*/
|
|
27
|
+
export function createEventLru(capacity = 256) {
|
|
28
|
+
const cap = Number.isSafeInteger(capacity) && capacity > 0 ? capacity : 256;
|
|
29
|
+
const entries = new Map();
|
|
30
|
+
return {
|
|
31
|
+
get size() {
|
|
32
|
+
return entries.size;
|
|
33
|
+
},
|
|
34
|
+
get(key) {
|
|
35
|
+
if (!entries.has(key)) return undefined;
|
|
36
|
+
const value = entries.get(key);
|
|
37
|
+
entries.delete(key);
|
|
38
|
+
entries.set(key, value);
|
|
39
|
+
return value;
|
|
40
|
+
},
|
|
41
|
+
set(key, value) {
|
|
42
|
+
if (entries.has(key)) entries.delete(key);
|
|
43
|
+
entries.set(key, value);
|
|
44
|
+
while (entries.size > cap) {
|
|
45
|
+
const oldest = entries.keys().next().value;
|
|
46
|
+
entries.delete(oldest);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
},
|
|
50
|
+
delete(key) {
|
|
51
|
+
return entries.delete(key);
|
|
52
|
+
},
|
|
53
|
+
clear() {
|
|
54
|
+
entries.clear();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Walk `startReplyTo` upward through the reply relations the platform reports.
|
|
61
|
+
* @param {Object} input
|
|
62
|
+
* @param {(eventId: string) => Promise<{sender?: string, name?: string, text: string, replyTo?: ?string, ts?: ?number}|null>} input.fetch
|
|
63
|
+
* Resolves one event, or null when it is gone, undecryptable, or not a message.
|
|
64
|
+
* @param {?string} input.startReplyTo The reply target of the triggering message.
|
|
65
|
+
* @param {number} [input.maxDepth=5]
|
|
66
|
+
* @returns {Promise<ReplyChainNode[]>} oldest ancestor first; empty when there is no chain.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveReplyChain({ fetch, startReplyTo, maxDepth = 5 } = {}) {
|
|
69
|
+
const chain = [];
|
|
70
|
+
if (typeof fetch !== 'function') return chain;
|
|
71
|
+
const depth = Number.isSafeInteger(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, 10) : 5;
|
|
72
|
+
const visited = new Set();
|
|
73
|
+
let cursor = typeof startReplyTo === 'string' && startReplyTo ? startReplyTo : null;
|
|
74
|
+
while (cursor && !visited.has(cursor) && chain.length < depth) {
|
|
75
|
+
visited.add(cursor);
|
|
76
|
+
let event = null;
|
|
77
|
+
try {
|
|
78
|
+
event = await fetch(cursor);
|
|
79
|
+
} catch {
|
|
80
|
+
// A channel lookup failure leaves the chain exactly as far as it reached;
|
|
81
|
+
// a partial explicit context still beats dropping the whole turn's context.
|
|
82
|
+
event = null;
|
|
83
|
+
}
|
|
84
|
+
if (!event || typeof event.text !== 'string' || !event.text) break;
|
|
85
|
+
chain.push({
|
|
86
|
+
eventId: cursor,
|
|
87
|
+
sender: typeof event.sender === 'string' ? event.sender : '',
|
|
88
|
+
name: typeof event.name === 'string' ? event.name : '',
|
|
89
|
+
text: event.text,
|
|
90
|
+
ts: Number.isSafeInteger(event.ts) && event.ts > 0 ? event.ts : null,
|
|
91
|
+
});
|
|
92
|
+
cursor = typeof event.replyTo === 'string' && event.replyTo ? event.replyTo : null;
|
|
93
|
+
}
|
|
94
|
+
return chain.reverse();
|
|
95
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-consumption registry for conversation roots (Feishu topic roots, Matrix
|
|
3
|
+
* thread roots): the first message of a thread is context for that thread's AI
|
|
4
|
+
* conversation, but it must not be re-fed on every turn.
|
|
5
|
+
*
|
|
6
|
+
* The contract mirrors the behavior shipped for Feishu topics in CHANGELOG
|
|
7
|
+
* 4.24.1: the root rides along only until one turn using it completes
|
|
8
|
+
* successfully — commands, rejected messages, and failed lookups or requests
|
|
9
|
+
* never consume it — and a cleared, switched, or rebuilt session receives it
|
|
10
|
+
* again. Anchors persist through the conversation state store's extension
|
|
11
|
+
* mechanism so a restart does not re-inject; a state without that mechanism
|
|
12
|
+
* (tests, older stores) falls back to an in-process table.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class RootContextRegistry {
|
|
16
|
+
#state;
|
|
17
|
+
// Same shape as the persisted anchor table so every accessor reads one kind of value.
|
|
18
|
+
#fallback = Object.create(null);
|
|
19
|
+
|
|
20
|
+
constructor({ state = null } = {}) {
|
|
21
|
+
this.#state = state;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The live anchor table, creating it inside the persisted state when possible. */
|
|
25
|
+
#table(create = false) {
|
|
26
|
+
const extension = typeof this.#state?.extensionState === 'function' ? this.#state.extensionState() : null;
|
|
27
|
+
if (extension && typeof extension === 'object') {
|
|
28
|
+
if (extension.rootAnchors && typeof extension.rootAnchors === 'object') return extension.rootAnchors;
|
|
29
|
+
if (create) extension.rootAnchors = Object.create(null);
|
|
30
|
+
return extension ? extension.rootAnchors ?? null : null;
|
|
31
|
+
}
|
|
32
|
+
return this.#fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#persist() {
|
|
36
|
+
if (typeof this.#state?.persist === 'function') return this.#state.persist();
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{sessionId: ?string, rootId: ?string}|null} anchor
|
|
42
|
+
*/
|
|
43
|
+
anchorFor(routeKey) {
|
|
44
|
+
const anchor = this.#table(false)?.[routeKey];
|
|
45
|
+
if (!anchor || typeof anchor !== 'object') return null;
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
sessionId: typeof anchor.sessionId === 'string' && anchor.sessionId ? anchor.sessionId : null,
|
|
48
|
+
rootId: typeof anchor.rootId === 'string' && anchor.rootId ? anchor.rootId : null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the root context should ride along on the next prompt.
|
|
54
|
+
* @param {{routeKey: string, sessionId: ?string, rootId: ?string}} request
|
|
55
|
+
* @returns {{inject: boolean, reason: string}}
|
|
56
|
+
*/
|
|
57
|
+
plan({ routeKey, sessionId = null, rootId = null } = {}) {
|
|
58
|
+
if (!routeKey || typeof rootId !== 'string' || !rootId) {
|
|
59
|
+
return Object.freeze({ inject: false, reason: 'no-root' });
|
|
60
|
+
}
|
|
61
|
+
const anchor = this.anchorFor(routeKey);
|
|
62
|
+
const effectiveSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
63
|
+
if (!anchor) return Object.freeze({ inject: true, reason: 'fresh' });
|
|
64
|
+
if (anchor.rootId !== rootId) return Object.freeze({ inject: true, reason: 'root-changed' });
|
|
65
|
+
if (anchor.sessionId !== effectiveSessionId) return Object.freeze({ inject: true, reason: 'session-changed' });
|
|
66
|
+
return Object.freeze({ inject: false, reason: 'consumed' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record a successful consumption; only call this after the turn succeeded. */
|
|
70
|
+
async markInjected({ routeKey, sessionId = null, rootId } = {}) {
|
|
71
|
+
if (!routeKey || typeof rootId !== 'string' || !rootId) return false;
|
|
72
|
+
const table = this.#table(true);
|
|
73
|
+
if (!table) return false;
|
|
74
|
+
table[routeKey] = {
|
|
75
|
+
sessionId: typeof sessionId === 'string' && sessionId ? sessionId : null,
|
|
76
|
+
rootId,
|
|
77
|
+
at: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
await this.#persist();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop one route's anchor so the next successful turn re-receives the root (a cleared session). */
|
|
84
|
+
async clearRoute(routeKey) {
|
|
85
|
+
const table = this.#table(false);
|
|
86
|
+
if (!table || !(routeKey in table)) return false;
|
|
87
|
+
delete table[routeKey];
|
|
88
|
+
await this.#persist();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Drop every anchor (a full reset of the conversation state). */
|
|
93
|
+
async clearAll() {
|
|
94
|
+
const table = this.#table(false);
|
|
95
|
+
if (table) {
|
|
96
|
+
for (const key of Object.keys(table)) delete table[key];
|
|
97
|
+
await this.#persist();
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(this.#fallback)) delete this.#fallback[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-neutral conversation routes plus the single generator of the
|
|
3
|
+
* conversation session keys stored in a channel's state file.
|
|
4
|
+
*
|
|
5
|
+
* The codecs encode the established on-disk shapes so adopting this module
|
|
6
|
+
* never rewrites an existing key: Matrix keeps `direct:dm:<lower user id>`
|
|
7
|
+
* and `group:room:<roomId>[$<thread root>]` (event ids themselves begin with
|
|
8
|
+
* `$`, so a thread key reads with a `$$` run on disk); Feishu group chats keep
|
|
9
|
+
* `group:<chatId>` and `group:<chatId>:thread:<threadId>`. Feishu p2p keys
|
|
10
|
+
* are produced by the channel's own `conversationKey` helper until the parity
|
|
11
|
+
* cutover moves them here; the codec therefore refuses to encode a Feishu dm
|
|
12
|
+
* route instead of inventing a second shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} ConversationRoute
|
|
17
|
+
* @property {string} channel Codec selector ('matrix', 'feishu', ...).
|
|
18
|
+
* @property {?string} botId Bot identity for namespacing; null when the channel key is not bot-scoped.
|
|
19
|
+
* @property {string} chatId Room / chat id: Matrix `room_id`, Feishu `chat_id`.
|
|
20
|
+
* @property {?string} senderId Sender identity, required by dm routes.
|
|
21
|
+
* @property {?string} threadId Server-side thread root (Matrix event id, Feishu thread_id); null on main-feed routes.
|
|
22
|
+
* @property {'dm'|'group'|'thread'} scope
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const MATRIX_CODEC = Object.freeze({
|
|
26
|
+
dm: (route) => `direct:dm:${String(route.senderId ?? '').trim().toLowerCase()}`,
|
|
27
|
+
group: (route) => `group:room:${route.chatId}${route.threadId ? `$${route.threadId}` : ''}`,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const FEISHU_CODEC = Object.freeze({
|
|
31
|
+
dm: null,
|
|
32
|
+
group: (route) => (route.threadId ? `group:${route.chatId}:thread:${route.threadId}` : `group:${route.chatId}`),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CODECS = Object.freeze({ matrix: MATRIX_CODEC, feishu: FEISHU_CODEC });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build one frozen conversation route from the channel-neutral parts.
|
|
39
|
+
* @param {Object} parts - route parts; `kind` accepts 'direct'|'dm'|'group'.
|
|
40
|
+
* @returns {ConversationRoute}
|
|
41
|
+
*/
|
|
42
|
+
export function buildConversationRoute({ channel, botId = null, chatId, senderId = null, threadId = null, kind = 'group' } = {}) {
|
|
43
|
+
if (typeof channel !== 'string' || !channel) throw new TypeError('A conversation route requires a channel');
|
|
44
|
+
if (typeof chatId !== 'string' || !chatId) throw new TypeError('A conversation route requires a chat id');
|
|
45
|
+
const scope = kind === 'direct' || kind === 'dm'
|
|
46
|
+
? 'dm'
|
|
47
|
+
: (typeof threadId === 'string' && threadId ? 'thread' : 'group');
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
channel,
|
|
50
|
+
botId: typeof botId === 'string' && botId ? botId : null,
|
|
51
|
+
chatId,
|
|
52
|
+
senderId: typeof senderId === 'string' && senderId ? senderId : null,
|
|
53
|
+
threadId: scope === 'thread' ? threadId : null,
|
|
54
|
+
scope,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The one place conversation session keys are produced.
|
|
60
|
+
* @param {ConversationRoute} route
|
|
61
|
+
* @returns {string} the durable conversation key, e.g. `group:room:!a:x.org$$root:y.org`.
|
|
62
|
+
*/
|
|
63
|
+
export function encodeConversationRouteKey(route) {
|
|
64
|
+
const codec = CODECS[route?.channel];
|
|
65
|
+
if (!codec) throw new TypeError(`No conversation key codec is registered for channel "${route?.channel ?? '?'}"`);
|
|
66
|
+
const encode = codec[route.scope === 'dm' ? 'dm' : 'group'];
|
|
67
|
+
if (typeof encode !== 'function') {
|
|
68
|
+
throw new TypeError(`Channel "${route.channel}" cannot encode a ${route.scope} route`);
|
|
69
|
+
}
|
|
70
|
+
return encode(route);
|
|
71
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Semantic Message contract every channel adapter normalizes toward.
|
|
3
|
+
*
|
|
4
|
+
* CONTEXT.md separates what must stay platform-neutral (this shape and the
|
|
5
|
+
* conversation route) from the native channel action (cards, edits, reactions,
|
|
6
|
+
* threads as delivery targets). Only the former lives here; anything a channel
|
|
7
|
+
* produces through its own API stays in the channel directory. The shape is a
|
|
8
|
+
* duck-typed contract: existing channel-normalized messages already satisfy it
|
|
9
|
+
* field by field, so adapters may adopt `createSemanticMessage` incrementally.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} MessageRef
|
|
14
|
+
* @property {string} id Platform event/message id.
|
|
15
|
+
* @property {?string} sender Author identity of the referenced message.
|
|
16
|
+
* @property {?number} timestamp Origin ms when known.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} SemanticMessage
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {string} channel
|
|
23
|
+
* @property {?string} botId
|
|
24
|
+
* @property {string} chatId Room / chat id.
|
|
25
|
+
* @property {?string} threadId Thread root when the message lives in one.
|
|
26
|
+
* @property {{id: string, name?: string}} sender
|
|
27
|
+
* @property {number} timestamp Origin ms; ingestion time when the platform hides it.
|
|
28
|
+
* @property {string} content Model-facing text, verbatim framing is producer-owned.
|
|
29
|
+
* @property {?MessageRef} replyTo
|
|
30
|
+
* @property {{mentionedBot: boolean, repliedToBot: boolean, commandLike: boolean}} trigger
|
|
31
|
+
* @property {'dm'|'group'} kind
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function clean(value) {
|
|
35
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate and freeze one semantic message. Throws on identity-bearing fields;
|
|
40
|
+
* optional fields normalize to null instead of throwing so an adapter can
|
|
41
|
+
* forward a best-effort projection.
|
|
42
|
+
* @param {Object} fields
|
|
43
|
+
* @returns {SemanticMessage}
|
|
44
|
+
*/
|
|
45
|
+
export function createSemanticMessage({
|
|
46
|
+
id, channel, botId = null, chatId, threadId = null,
|
|
47
|
+
sender = {}, timestamp, content = '', replyTo = null, trigger = {}, kind = 'group',
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (typeof id !== 'string' || !id) throw new TypeError('A semantic message requires an id');
|
|
50
|
+
if (typeof channel !== 'string' || !channel) throw new TypeError('A semantic message requires a channel');
|
|
51
|
+
if (typeof chatId !== 'string' || !chatId) throw new TypeError('A semantic message requires a chat id');
|
|
52
|
+
const senderId = clean(sender?.id);
|
|
53
|
+
if (!senderId) throw new TypeError('A semantic message requires a sender id');
|
|
54
|
+
const senderName = clean(sender?.name);
|
|
55
|
+
const replyId = clean(replyTo?.id);
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
id,
|
|
58
|
+
channel,
|
|
59
|
+
botId: clean(botId),
|
|
60
|
+
chatId,
|
|
61
|
+
threadId: clean(threadId),
|
|
62
|
+
sender: Object.freeze(senderName ? { id: senderId, name: senderName } : { id: senderId }),
|
|
63
|
+
timestamp: Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : Date.now(),
|
|
64
|
+
content: typeof content === 'string' ? content : '',
|
|
65
|
+
replyTo: replyId
|
|
66
|
+
? Object.freeze({ id: replyId, sender: clean(replyTo?.sender), timestamp: Number.isSafeInteger(replyTo?.timestamp) && replyTo.timestamp > 0 ? replyTo.timestamp : null })
|
|
67
|
+
: null,
|
|
68
|
+
trigger: Object.freeze({
|
|
69
|
+
mentionedBot: trigger?.mentionedBot === true,
|
|
70
|
+
repliedToBot: trigger?.repliedToBot === true,
|
|
71
|
+
commandLike: trigger?.commandLike === true,
|
|
72
|
+
}),
|
|
73
|
+
kind: kind === 'direct' || kind === 'dm' ? 'dm' : 'group',
|
|
74
|
+
});
|
|
75
|
+
}
|