@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.
@@ -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
+ }