@mininglamp-oss/cc-channel-octo 1.0.1-dev.60b73f3

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 (87) hide show
  1. package/CHANGELOG.md +349 -0
  2. package/LICENSE +191 -0
  3. package/README.md +577 -0
  4. package/config.bot.example.json +15 -0
  5. package/config.example.json +33 -0
  6. package/dist/agent-bridge.d.ts +79 -0
  7. package/dist/agent-bridge.js +392 -0
  8. package/dist/agent-bridge.js.map +1 -0
  9. package/dist/commands.d.ts +57 -0
  10. package/dist/commands.js +121 -0
  11. package/dist/commands.js.map +1 -0
  12. package/dist/config.d.ts +287 -0
  13. package/dist/config.js +332 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/cron-evaluator.d.ts +53 -0
  16. package/dist/cron-evaluator.js +191 -0
  17. package/dist/cron-evaluator.js.map +1 -0
  18. package/dist/cron-fire-marker.d.ts +24 -0
  19. package/dist/cron-fire-marker.js +25 -0
  20. package/dist/cron-fire-marker.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +46 -0
  22. package/dist/cron-scheduler.js +114 -0
  23. package/dist/cron-scheduler.js.map +1 -0
  24. package/dist/cron-store.d.ts +62 -0
  25. package/dist/cron-store.js +63 -0
  26. package/dist/cron-store.js.map +1 -0
  27. package/dist/cron-tool.d.ts +44 -0
  28. package/dist/cron-tool.js +151 -0
  29. package/dist/cron-tool.js.map +1 -0
  30. package/dist/cwd-resolver.d.ts +72 -0
  31. package/dist/cwd-resolver.js +166 -0
  32. package/dist/cwd-resolver.js.map +1 -0
  33. package/dist/db-adapter.d.ts +21 -0
  34. package/dist/db-adapter.js +64 -0
  35. package/dist/db-adapter.js.map +1 -0
  36. package/dist/file-inline-wrap.d.ts +94 -0
  37. package/dist/file-inline-wrap.js +243 -0
  38. package/dist/file-inline-wrap.js.map +1 -0
  39. package/dist/gateway.d.ts +100 -0
  40. package/dist/gateway.js +420 -0
  41. package/dist/gateway.js.map +1 -0
  42. package/dist/group-config.d.ts +41 -0
  43. package/dist/group-config.js +104 -0
  44. package/dist/group-config.js.map +1 -0
  45. package/dist/group-context.d.ts +81 -0
  46. package/dist/group-context.js +466 -0
  47. package/dist/group-context.js.map +1 -0
  48. package/dist/inbound.d.ts +136 -0
  49. package/dist/inbound.js +667 -0
  50. package/dist/inbound.js.map +1 -0
  51. package/dist/index.d.ts +33 -0
  52. package/dist/index.js +932 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/media-inbound.d.ts +38 -0
  55. package/dist/media-inbound.js +131 -0
  56. package/dist/media-inbound.js.map +1 -0
  57. package/dist/mention-utils.d.ts +108 -0
  58. package/dist/mention-utils.js +199 -0
  59. package/dist/mention-utils.js.map +1 -0
  60. package/dist/octo/api.d.ts +148 -0
  61. package/dist/octo/api.js +320 -0
  62. package/dist/octo/api.js.map +1 -0
  63. package/dist/octo/socket.d.ts +102 -0
  64. package/dist/octo/socket.js +793 -0
  65. package/dist/octo/socket.js.map +1 -0
  66. package/dist/octo/types.d.ts +126 -0
  67. package/dist/octo/types.js +35 -0
  68. package/dist/octo/types.js.map +1 -0
  69. package/dist/prompt-safety.d.ts +78 -0
  70. package/dist/prompt-safety.js +148 -0
  71. package/dist/prompt-safety.js.map +1 -0
  72. package/dist/session-router.d.ts +144 -0
  73. package/dist/session-router.js +490 -0
  74. package/dist/session-router.js.map +1 -0
  75. package/dist/session-store.d.ts +89 -0
  76. package/dist/session-store.js +297 -0
  77. package/dist/session-store.js.map +1 -0
  78. package/dist/skill-linker.d.ts +31 -0
  79. package/dist/skill-linker.js +160 -0
  80. package/dist/skill-linker.js.map +1 -0
  81. package/dist/stream-relay.d.ts +42 -0
  82. package/dist/stream-relay.js +243 -0
  83. package/dist/stream-relay.js.map +1 -0
  84. package/dist/url-policy.d.ts +103 -0
  85. package/dist/url-policy.js +290 -0
  86. package/dist/url-policy.js.map +1 -0
  87. package/package.json +79 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * v1.0: GROUP.md / THREAD.md per-conversation instruction files.
3
+ *
4
+ * Operators can drop a markdown file with custom instructions for a specific
5
+ * group (or, later, thread) into a configured directory. Its contents are
6
+ * injected into the agent's system prompt as a trusted instruction block, so a
7
+ * group can have its own persona / rules without code changes.
8
+ *
9
+ * SECURITY — read carefully. The `[Group instructions]` block is injected into
10
+ * the system prompt UNSANITIZED, so its contents are trusted. That trust holds
11
+ * ONLY if the file is writable solely by the operator (the gateway process user).
12
+ *
13
+ * Placing `groupConfigDir` outside `cwdBase` is necessary but NOT sufficient:
14
+ * under the shipped defaults (`allowedTools: '*'`, `bypassPermissions`) the agent
15
+ * has `Bash`/`Write` and can write ABSOLUTE paths anywhere the gateway user can
16
+ * write — `cwdBase` is a starting dir, not a chroot. So a malicious user in one
17
+ * group could drive the agent to write `<groupConfigDir>/<otherGroup>.md` and
18
+ * inject persistent, trusted instructions into a different group.
19
+ *
20
+ * The real protection is OS-level: `groupConfigDir` and its files MUST be made
21
+ * non-writable by the gateway process user (e.g. root-owned, mode 0755/0644),
22
+ * and/or the deployment hardened (drop `Bash`, sandboxed FS, unprivileged user).
23
+ * As cheap defense-in-depth, loadGroupConfig() refuses to inject a file that is
24
+ * group- or world-writable. The group id is filename-pinned to a safe slug so a
25
+ * crafted id can't traverse out of the config dir.
26
+ */
27
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ /** Max bytes of an instruction file we will inject (keeps the prompt bounded). */
30
+ export const MAX_GROUP_CONFIG_BYTES = 16_384; // 16 KiB
31
+ /**
32
+ * Only allow ids that are safe as a single path segment — letters, digits, and
33
+ * a few separators. Anything else (slashes, dots-only, `..`) is rejected so a
34
+ * channel/thread id cannot escape `groupConfigDir`.
35
+ */
36
+ function isSafeId(id) {
37
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id !== '.' && id !== '..';
38
+ }
39
+ /**
40
+ * Load the instruction file for a group, or undefined when none applies.
41
+ *
42
+ * Looks for `<groupConfigDir>/<groupId>.md`. Returns the trimmed contents,
43
+ * truncated to MAX_GROUP_CONFIG_BYTES. Returns undefined when:
44
+ * - groupConfigDir is not configured,
45
+ * - groupId is empty or unsafe as a path segment,
46
+ * - the file does not exist or is unreadable.
47
+ *
48
+ * Never throws — a misconfigured dir or unreadable file degrades to "no custom
49
+ * instructions" rather than failing the turn.
50
+ */
51
+ export function loadGroupConfig(groupConfigDir, groupId) {
52
+ if (!groupConfigDir)
53
+ return undefined;
54
+ if (!groupId || !isSafeId(groupId))
55
+ return undefined;
56
+ const path = join(groupConfigDir, `${groupId}.md`);
57
+ try {
58
+ if (!existsSync(path))
59
+ return undefined;
60
+ const st = statSync(path);
61
+ if (!st.isFile())
62
+ return undefined;
63
+ // Defense-in-depth: refuse a group/world-writable file. Its contents are
64
+ // injected UNSANITIZED into the system prompt, so a file anyone-but-the-
65
+ // operator can write is an untrusted injection sink. This catches the most
66
+ // common misconfiguration; it is NOT a substitute for proper OS perms +
67
+ // a hardened deployment (the agent can still write operator-owned paths
68
+ // under default Bash/bypassPermissions — see the module header).
69
+ if ((st.mode & 0o022) !== 0) {
70
+ console.error(`[cc-channel-octo] refusing group config ${path}: file is group/world-writable ` +
71
+ `(mode ${(st.mode & 0o777).toString(8)}). Make it writable only by the gateway user.`);
72
+ return undefined;
73
+ }
74
+ // Read at most MAX+1 bytes so a mistakenly huge file can't block the event
75
+ // loop or allocate unbounded memory on every group turn. Reading one extra
76
+ // byte lets us detect (and mark) truncation.
77
+ const fd = openSync(path, 'r');
78
+ let content;
79
+ let wasTruncated = false;
80
+ try {
81
+ const buf = Buffer.allocUnsafe(MAX_GROUP_CONFIG_BYTES + 1);
82
+ const bytesRead = readSync(fd, buf, 0, MAX_GROUP_CONFIG_BYTES + 1, 0);
83
+ wasTruncated = bytesRead > MAX_GROUP_CONFIG_BYTES;
84
+ const slice = buf.subarray(0, Math.min(bytesRead, MAX_GROUP_CONFIG_BYTES));
85
+ content = slice.toString('utf-8');
86
+ }
87
+ finally {
88
+ closeSync(fd);
89
+ }
90
+ if (wasTruncated) {
91
+ // The slice may end mid-codepoint; trim back to a valid UTF-8 boundary by
92
+ // dropping any trailing replacement char produced by a split sequence.
93
+ content = content.replace(/�+$/, '');
94
+ content += '\n[… group config truncated]';
95
+ }
96
+ const trimmed = content.trim();
97
+ return trimmed.length > 0 ? trimmed : undefined;
98
+ }
99
+ catch (err) {
100
+ console.error(`[cc-channel-octo] group config read failed for ${path}: ${String(err)}`);
101
+ return undefined;
102
+ }
103
+ }
104
+ //# sourceMappingURL=group-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"group-config.js","sourceRoot":"","sources":["../src/group-config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Group Context — group chat message cache + maxContextChars budget + mention mapping.
3
+ */
4
+ import type { DbAdapter } from './db-adapter.js';
5
+ export declare class GroupContext {
6
+ private readonly messageCache;
7
+ private readonly memberMapByChannel;
8
+ private readonly nameToUidByChannel;
9
+ private readonly robotFlags;
10
+ private readonly lastRefresh;
11
+ private readonly adapter;
12
+ private readonly maxContextChars;
13
+ private readonly maxWindowSize;
14
+ private upsertMember;
15
+ private deleteMember;
16
+ private selectMembers;
17
+ private insertMessage;
18
+ private selectRecentMessages;
19
+ private deleteOldMessages;
20
+ private selectMessagesSince;
21
+ private selectMaxId;
22
+ private upsertCursor;
23
+ private selectCursor;
24
+ constructor(adapter: DbAdapter, maxContextChars: number);
25
+ private initStatements;
26
+ private getMemberMap;
27
+ private getNameToUid;
28
+ pushMessage(channelId: string, fromUid: string, fromName: string, content: string, timestamp: number): void;
29
+ learnMember(channelId: string, uid: string, name: string): void;
30
+ refreshMembers(channelId: string, apiUrl: string, botToken: string): Promise<void>;
31
+ fetchAndLearnUser(uid: string, channelId: string, apiUrl: string, botToken: string): Promise<string | undefined>;
32
+ buildContext(channelId: string): string;
33
+ /** Read the per-channel consumption cursor (highest already-injected id), or 0. */
34
+ getContextCursor(channelId: string): number;
35
+ /** Advance the per-channel cursor to `lastId` (monotonic — never moves backward). */
36
+ setContextCursor(channelId: string, lastId: number): void;
37
+ /** The highest group_messages.id for a channel (for cursor priming), or 0. */
38
+ getMaxMessageId(channelId: string): number;
39
+ /**
40
+ * Build a group-context block from messages NEWER than `sinceId` (the delta the
41
+ * model hasn't seen yet), within `maxContextChars`. Returns the block text (same
42
+ * `[Recent group messages]` format as buildContext) and the highest id that
43
+ * EXISTS in the channel above `sinceId` (so the caller advances the cursor past
44
+ * the whole delta, including any oldest lines the char budget dropped — those
45
+ * are the least-relevant and are intentionally not re-shown). Empty text + the
46
+ * unchanged cursor when there's nothing new.
47
+ *
48
+ * Rows are fetched newest-first (so a backlog larger than the budget keeps the
49
+ * most-recent messages); the selected slice is reversed back to chronological
50
+ * order for display. Unlike buildContext (in-memory rolling window), this reads
51
+ * from the DB so the cursor delta is exact even across restarts / window eviction.
52
+ */
53
+ buildContextSince(channelId: string, sinceId: number): {
54
+ text: string;
55
+ lastId: number;
56
+ };
57
+ resolveMentions(text: string, channelId: string): string[];
58
+ getName(uid: string, channelId: string): string | undefined;
59
+ /** G23: Check the server-authoritative robot flag for a group member. */
60
+ isRobot(channelId: string, uid: string): boolean | undefined;
61
+ /**
62
+ * A8 (#143): true iff `uid` is a current member of `channelId` per the cached
63
+ * member list. The list is kept authoritative by refreshMembers (it prunes
64
+ * departed members, not just upserts) — but it is only best-effort fresh:
65
+ * refresh is throttled to REFRESH_INTERVAL_MS and seeded from DB on restart,
66
+ * so a membership change inside that window may not be reflected yet. Used by
67
+ * the outbound mention guard as defense-in-depth (the server still enforces
68
+ * real permissions), NOT as a real-time authority.
69
+ */
70
+ isMember(channelId: string, uid: string): boolean;
71
+ /**
72
+ * A8 (#143): the channel's displayName→uid map, for v1 `@name` outbound
73
+ * resolution in StreamRelay.deliver. Returns the live map (empty if the
74
+ * channel has no cached members yet). Read-only use by callers.
75
+ */
76
+ getNameToUidMap(channelId: string): Map<string, string>;
77
+ loadMembersFromDb(channelId: string): void;
78
+ loadMessagesFromDb(channelId: string): void;
79
+ /** Load all persisted members and messages from DB (call once at startup). */
80
+ loadAllFromDb(): void;
81
+ }
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Group Context — group chat message cache + maxContextChars budget + mention mapping.
3
+ */
4
+ import { getGroupMembers, fetchUserInfo } from './octo/api.js';
5
+ import { sanitizeDisplayName } from './prompt-safety.js';
6
+ const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
7
+ export class GroupContext {
8
+ messageCache = new Map();
9
+ // Per-channel member maps to avoid cross-group name collisions
10
+ memberMapByChannel = new Map(); // channelId → uid → name
11
+ nameToUidByChannel = new Map(); // channelId → name → uid
12
+ // G23: per-channel robot flag map (channelId → uid → isRobot).
13
+ // Populated from refreshMembers; stored for future routing integrations
14
+ // (e.g. 免@ gate that should treat bot members differently).
15
+ robotFlags = new Map();
16
+ lastRefresh = new Map();
17
+ adapter;
18
+ maxContextChars;
19
+ maxWindowSize;
20
+ upsertMember;
21
+ deleteMember;
22
+ selectMembers;
23
+ insertMessage;
24
+ selectRecentMessages;
25
+ deleteOldMessages;
26
+ selectMessagesSince;
27
+ selectMaxId;
28
+ upsertCursor;
29
+ selectCursor;
30
+ constructor(adapter, maxContextChars) {
31
+ this.adapter = adapter;
32
+ this.maxContextChars = maxContextChars;
33
+ this.maxWindowSize = 100;
34
+ this.initStatements();
35
+ }
36
+ initStatements() {
37
+ this.adapter.exec(`
38
+ CREATE TABLE IF NOT EXISTS group_messages (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ channel_id TEXT NOT NULL,
41
+ from_uid TEXT NOT NULL,
42
+ from_name TEXT NOT NULL,
43
+ content TEXT NOT NULL,
44
+ timestamp INTEGER NOT NULL
45
+ )
46
+ `);
47
+ this.adapter.exec(`
48
+ CREATE INDEX IF NOT EXISTS idx_group_messages_channel
49
+ ON group_messages (channel_id, id DESC)
50
+ `);
51
+ // Per-channel consumption cursor: the highest group_messages.id that has
52
+ // already been injected into a turn for this channel. Only group messages
53
+ // NEWER than this are injected next turn (the bot's standing context lives in
54
+ // the SDK session, so re-injecting the whole window every turn would be both
55
+ // redundant and a frozen-prompt violation). Mirrors the reset_barriers /
56
+ // sdk_sessions single-row-per-key pattern.
57
+ this.adapter.exec(`
58
+ CREATE TABLE IF NOT EXISTS group_context_cursors (
59
+ channel_id TEXT PRIMARY KEY,
60
+ last_id INTEGER NOT NULL,
61
+ updated_at INTEGER NOT NULL
62
+ )
63
+ `);
64
+ this.upsertMember = this.adapter.prepare('INSERT INTO group_members (group_id, uid, name, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(group_id, uid) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at');
65
+ this.deleteMember = this.adapter.prepare('DELETE FROM group_members WHERE group_id = ? AND uid = ?');
66
+ this.selectMembers = this.adapter.prepare('SELECT uid, name FROM group_members WHERE group_id = ?');
67
+ this.insertMessage = this.adapter.prepare('INSERT INTO group_messages (channel_id, from_uid, from_name, content, timestamp) VALUES (?, ?, ?, ?, ?)');
68
+ this.selectRecentMessages = this.adapter.prepare('SELECT from_uid, from_name, content, timestamp FROM group_messages WHERE channel_id = ? ORDER BY id DESC LIMIT ?');
69
+ this.deleteOldMessages = this.adapter.prepare('DELETE FROM group_messages WHERE channel_id = ? AND id NOT IN (SELECT id FROM group_messages WHERE channel_id = ? ORDER BY id DESC LIMIT ?)');
70
+ // Cursor delta: messages strictly newer than the cursor, NEWEST-first so a
71
+ // backlog larger than the fetch limit keeps the most-recent messages (the
72
+ // relevant ones) rather than ancient ones. LIMIT (maxWindowSize=100) is <=
73
+ // deleteOldMessages' retained 200, so every fetchable row survives trimming.
74
+ // buildContextSince re-sorts the budget-selected slice into chronological
75
+ // order for display. Mirrors the in-memory buildContext rolling-window.
76
+ this.selectMessagesSince = this.adapter.prepare('SELECT id, from_name, content FROM group_messages WHERE channel_id = ? AND id > ? ORDER BY id DESC LIMIT ?');
77
+ this.selectMaxId = this.adapter.prepare('SELECT MAX(id) AS maxId FROM group_messages WHERE channel_id = ?');
78
+ this.upsertCursor = this.adapter.prepare('INSERT INTO group_context_cursors (channel_id, last_id, updated_at) VALUES (?, ?, ?) ' +
79
+ 'ON CONFLICT(channel_id) DO UPDATE SET last_id = excluded.last_id, updated_at = excluded.updated_at ' +
80
+ 'WHERE excluded.last_id > group_context_cursors.last_id');
81
+ this.selectCursor = this.adapter.prepare('SELECT last_id FROM group_context_cursors WHERE channel_id = ?');
82
+ }
83
+ getMemberMap(channelId) {
84
+ let m = this.memberMapByChannel.get(channelId);
85
+ if (!m) {
86
+ m = new Map();
87
+ this.memberMapByChannel.set(channelId, m);
88
+ }
89
+ return m;
90
+ }
91
+ getNameToUid(channelId) {
92
+ let m = this.nameToUidByChannel.get(channelId);
93
+ if (!m) {
94
+ m = new Map();
95
+ this.nameToUidByChannel.set(channelId, m);
96
+ }
97
+ return m;
98
+ }
99
+ pushMessage(channelId, fromUid, fromName, content, timestamp) {
100
+ // SECURITY: fromName is the user-controlled IM display name and is rendered
101
+ // into the [Group context] block as `<name>:<content>`. Bound + strip it at
102
+ // the boundary (shared choke point) so it can't forge a label/line. fromUid
103
+ // is ALSO user-controlled, and sanitizeDisplayName returns its fallback
104
+ // verbatim — so sanitize the uid fallback too rather than passing it raw
105
+ // (PR #128 review: raw-uid-as-fallback re-introduces the injection).
106
+ const safeName = sanitizeDisplayName(fromName) || sanitizeDisplayName(fromUid) || 'unknown';
107
+ let window = this.messageCache.get(channelId);
108
+ if (!window) {
109
+ window = [];
110
+ this.messageCache.set(channelId, window);
111
+ }
112
+ window.push({ fromUid, fromName: safeName, content, timestamp });
113
+ while (window.length > this.maxWindowSize) {
114
+ window.shift();
115
+ }
116
+ this.learnMember(channelId, fromUid, safeName);
117
+ try {
118
+ this.insertMessage.run(channelId, fromUid, safeName, content, timestamp);
119
+ // Trim old messages to keep DB bounded (keep 2x window for safety)
120
+ this.deleteOldMessages.run(channelId, channelId, this.maxWindowSize * 2);
121
+ }
122
+ catch (err) {
123
+ console.error(`group-context: insert message failed: ${String(err)}`);
124
+ }
125
+ }
126
+ learnMember(channelId, uid, name) {
127
+ if (!uid || !name)
128
+ return;
129
+ const memberMap = this.getMemberMap(channelId);
130
+ const nameMap = this.getNameToUid(channelId);
131
+ const existing = memberMap.get(uid);
132
+ if (existing !== name) {
133
+ // Remove old reverse mapping only if it still points to THIS uid.
134
+ // If another user already claimed the same display name, don't clobber.
135
+ if (existing && nameMap.get(existing) === uid) {
136
+ nameMap.delete(existing);
137
+ }
138
+ memberMap.set(uid, name);
139
+ nameMap.set(name, uid);
140
+ try {
141
+ this.upsertMember.run(channelId, uid, name, Date.now());
142
+ }
143
+ catch (err) {
144
+ console.error(`group-context: upsert member failed: ${String(err)}`);
145
+ }
146
+ }
147
+ }
148
+ async refreshMembers(channelId, apiUrl, botToken) {
149
+ const now = Date.now();
150
+ const last = this.lastRefresh.get(channelId) ?? 0;
151
+ if (now - last < REFRESH_INTERVAL_MS)
152
+ return;
153
+ // Don't set lastRefresh here — only on success
154
+ try {
155
+ const members = await getGroupMembers({ apiUrl, botToken, groupNo: channelId });
156
+ this.lastRefresh.set(channelId, now); // Record only on success
157
+ const memberMap = this.getMemberMap(channelId);
158
+ const nameMap = this.getNameToUid(channelId);
159
+ // A8 (#143, take 2): the server response is AUTHORITATIVE — it is the full
160
+ // current roster, not a delta. Upserting returned members WITHOUT pruning
161
+ // departed ones (the original #144 bug, Jerry-Xin's 🔴) left a user who
162
+ // left the group cached forever, so isMember() kept accepting them and the
163
+ // outbound mention guard let a stale @uid through. Track who the server
164
+ // returned, then drop anyone cached/persisted who is no longer present.
165
+ //
166
+ // Best-effort caveat: this is only as fresh as the last successful refresh,
167
+ // which is throttled to REFRESH_INTERVAL_MS (1h) and seeded from DB on
168
+ // restart. A membership change inside that window is not reflected until
169
+ // the next refresh — the outbound guard is defense-in-depth, not a
170
+ // real-time authority. The server still enforces real permissions.
171
+ const present = new Set();
172
+ for (const m of members) {
173
+ if (!m.uid || !m.name)
174
+ continue;
175
+ present.add(m.uid);
176
+ const oldName = memberMap.get(m.uid);
177
+ if (oldName && oldName !== m.name && nameMap.get(oldName) === m.uid) {
178
+ nameMap.delete(oldName);
179
+ }
180
+ memberMap.set(m.uid, m.name);
181
+ nameMap.set(m.name, m.uid);
182
+ // G23: Track server-authoritative robot flag for future 免@ gate.
183
+ if (m.robot !== undefined) {
184
+ let rfMap = this.robotFlags.get(channelId);
185
+ if (!rfMap) {
186
+ rfMap = new Map();
187
+ this.robotFlags.set(channelId, rfMap);
188
+ }
189
+ rfMap.set(m.uid, m.robot === 1);
190
+ }
191
+ try {
192
+ this.upsertMember.run(channelId, m.uid, m.name, now);
193
+ }
194
+ catch (err) {
195
+ console.error(`group-context: upsert member failed: ${String(err)}`);
196
+ }
197
+ }
198
+ // Prune members no longer in the authoritative roster (memory + DB + robot
199
+ // flags). Iterate a snapshot of uids since we mutate the map in the loop.
200
+ //
201
+ // Guard: skip pruning when the roster came back EMPTY. getGroupMembers
202
+ // returns [] not only for a genuinely empty group but also for a
203
+ // malformed/unexpected-shape 200 response (data.members not an array →
204
+ // silently []). A group the bot is in always has ≥1 member, so an empty
205
+ // roster is far more likely a transient quirk than "everyone left".
206
+ // Mass-pruning on it would wipe the whole roster — and since lastRefresh
207
+ // was already set on this "success", it wouldn't re-fetch for an hour,
208
+ // downgrading every mention to plain text in that window. Keep the prior
209
+ // snapshot instead; a real emptying still prunes member-by-member as the
210
+ // roster shrinks across non-empty responses.
211
+ if (present.size > 0) {
212
+ const rfMap = this.robotFlags.get(channelId);
213
+ for (const uid of [...memberMap.keys()]) {
214
+ if (present.has(uid))
215
+ continue;
216
+ const staleName = memberMap.get(uid);
217
+ memberMap.delete(uid);
218
+ // Only remove the reverse entry if it still points at THIS uid (a rename
219
+ // may have already re-pointed the name to someone else).
220
+ if (staleName !== undefined && nameMap.get(staleName) === uid) {
221
+ nameMap.delete(staleName);
222
+ }
223
+ rfMap?.delete(uid);
224
+ try {
225
+ this.deleteMember.run(channelId, uid);
226
+ }
227
+ catch (err) {
228
+ console.error(`group-context: delete stale member failed: ${String(err)}`);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ catch (err) {
234
+ console.error(`group-context: refreshMembers(${channelId}) failed: ${String(err)}`);
235
+ // Don't update lastRefresh on failure — allow retry
236
+ }
237
+ }
238
+ async fetchAndLearnUser(uid, channelId, apiUrl, botToken) {
239
+ const memberMap = this.getMemberMap(channelId);
240
+ const cached = memberMap.get(uid);
241
+ if (cached)
242
+ return cached;
243
+ try {
244
+ const info = await fetchUserInfo({ apiUrl, botToken, uid });
245
+ if (info?.name) {
246
+ this.learnMember(channelId, uid, info.name);
247
+ return info.name;
248
+ }
249
+ }
250
+ catch (err) {
251
+ console.error(`group-context: fetchUserInfo(${uid}) failed: ${String(err)}`);
252
+ }
253
+ return undefined;
254
+ }
255
+ buildContext(channelId) {
256
+ const window = this.messageCache.get(channelId);
257
+ if (!window || window.length === 0)
258
+ return '';
259
+ const header = '[Recent group messages]\n';
260
+ const trailer = '\n';
261
+ const budget = this.maxContextChars - header.length - trailer.length;
262
+ if (budget <= 0)
263
+ return '';
264
+ const selected = [];
265
+ let used = 0;
266
+ for (let i = window.length - 1; i >= 0; i--) {
267
+ const m = window[i];
268
+ const line = `${m.fromName}:${m.content}`;
269
+ const cost = line.length + (selected.length > 0 ? 1 : 0);
270
+ if (used + cost > budget)
271
+ break;
272
+ selected.push(line);
273
+ used += cost;
274
+ }
275
+ if (selected.length === 0)
276
+ return '';
277
+ selected.reverse();
278
+ return `${header}${selected.join('\n')}${trailer}`;
279
+ }
280
+ /** Read the per-channel consumption cursor (highest already-injected id), or 0. */
281
+ getContextCursor(channelId) {
282
+ try {
283
+ const row = this.selectCursor.get(channelId);
284
+ return row?.last_id ?? 0;
285
+ }
286
+ catch (err) {
287
+ console.error(`group-context: getContextCursor(${channelId}) failed: ${String(err)}`);
288
+ return 0;
289
+ }
290
+ }
291
+ /** Advance the per-channel cursor to `lastId` (monotonic — never moves backward). */
292
+ setContextCursor(channelId, lastId) {
293
+ try {
294
+ this.upsertCursor.run(channelId, lastId, Date.now());
295
+ }
296
+ catch (err) {
297
+ console.error(`group-context: setContextCursor(${channelId}) failed: ${String(err)}`);
298
+ }
299
+ }
300
+ /** The highest group_messages.id for a channel (for cursor priming), or 0. */
301
+ getMaxMessageId(channelId) {
302
+ try {
303
+ const row = this.selectMaxId.get(channelId);
304
+ return row?.maxId ?? 0;
305
+ }
306
+ catch (err) {
307
+ console.error(`group-context: getMaxMessageId(${channelId}) failed: ${String(err)}`);
308
+ return 0;
309
+ }
310
+ }
311
+ /**
312
+ * Build a group-context block from messages NEWER than `sinceId` (the delta the
313
+ * model hasn't seen yet), within `maxContextChars`. Returns the block text (same
314
+ * `[Recent group messages]` format as buildContext) and the highest id that
315
+ * EXISTS in the channel above `sinceId` (so the caller advances the cursor past
316
+ * the whole delta, including any oldest lines the char budget dropped — those
317
+ * are the least-relevant and are intentionally not re-shown). Empty text + the
318
+ * unchanged cursor when there's nothing new.
319
+ *
320
+ * Rows are fetched newest-first (so a backlog larger than the budget keeps the
321
+ * most-recent messages); the selected slice is reversed back to chronological
322
+ * order for display. Unlike buildContext (in-memory rolling window), this reads
323
+ * from the DB so the cursor delta is exact even across restarts / window eviction.
324
+ */
325
+ buildContextSince(channelId, sinceId) {
326
+ let rows;
327
+ try {
328
+ rows = this.selectMessagesSince.all(channelId, sinceId, this.maxWindowSize);
329
+ }
330
+ catch (err) {
331
+ console.error(`group-context: buildContextSince(${channelId}) failed: ${String(err)}`);
332
+ return { text: '', lastId: sinceId };
333
+ }
334
+ if (rows.length === 0)
335
+ return { text: '', lastId: sinceId };
336
+ // rows are newest-first; the highest id is the first row. Advance the cursor to
337
+ // it regardless of what the budget keeps, so we never re-show a line.
338
+ const lastId = rows[0].id;
339
+ const header = '[Recent group messages]\n';
340
+ const trailer = '\n';
341
+ const budget = this.maxContextChars - header.length - trailer.length;
342
+ if (budget <= 0)
343
+ return { text: '', lastId };
344
+ // Walk newest→oldest (rows[0] is newest) keeping lines within budget.
345
+ const selected = [];
346
+ let used = 0;
347
+ for (let i = 0; i < rows.length; i++) {
348
+ const line = `${rows[i].from_name}:${rows[i].content}`;
349
+ const cost = line.length + (selected.length > 0 ? 1 : 0);
350
+ if (used + cost > budget)
351
+ break;
352
+ selected.push(line);
353
+ used += cost;
354
+ }
355
+ if (selected.length === 0)
356
+ return { text: '', lastId };
357
+ selected.reverse(); // chronological order for display
358
+ return { text: `${header}${selected.join('\n')}${trailer}`, lastId };
359
+ }
360
+ resolveMentions(text, channelId) {
361
+ const uids = [];
362
+ const seen = new Set();
363
+ const nameMap = this.nameToUidByChannel.get(channelId);
364
+ if (!nameMap)
365
+ return uids;
366
+ // Match @name where name is a run of word-like characters (letters, digits, underscores,
367
+ // CJK ideographs, Hangul, Kana, etc.) — stops at punctuation and whitespace.
368
+ const regex = /@([\w\u4e00-\u9fff\u3400-\u4dbf\uac00-\ud7af\u3040-\u309f\u30a0-\u30ff]+)/g;
369
+ let match;
370
+ while ((match = regex.exec(text)) !== null) {
371
+ // Strip known trailing punctuation that might stick to names
372
+ let name = match[1];
373
+ name = name.replace(/[,.!?;:,。!?;:、)\]]+$/, '');
374
+ if (!name)
375
+ continue;
376
+ const uid = nameMap.get(name);
377
+ if (uid && !seen.has(uid)) {
378
+ seen.add(uid);
379
+ uids.push(uid);
380
+ }
381
+ }
382
+ return uids;
383
+ }
384
+ getName(uid, channelId) {
385
+ return this.getMemberMap(channelId).get(uid);
386
+ }
387
+ /** G23: Check the server-authoritative robot flag for a group member. */
388
+ isRobot(channelId, uid) {
389
+ return this.robotFlags.get(channelId)?.get(uid);
390
+ }
391
+ /**
392
+ * A8 (#143): true iff `uid` is a current member of `channelId` per the cached
393
+ * member list. The list is kept authoritative by refreshMembers (it prunes
394
+ * departed members, not just upserts) — but it is only best-effort fresh:
395
+ * refresh is throttled to REFRESH_INTERVAL_MS and seeded from DB on restart,
396
+ * so a membership change inside that window may not be reflected yet. Used by
397
+ * the outbound mention guard as defense-in-depth (the server still enforces
398
+ * real permissions), NOT as a real-time authority.
399
+ */
400
+ isMember(channelId, uid) {
401
+ return this.memberMapByChannel.get(channelId)?.has(uid) ?? false;
402
+ }
403
+ /**
404
+ * A8 (#143): the channel's displayName→uid map, for v1 `@name` outbound
405
+ * resolution in StreamRelay.deliver. Returns the live map (empty if the
406
+ * channel has no cached members yet). Read-only use by callers.
407
+ */
408
+ getNameToUidMap(channelId) {
409
+ return this.getNameToUid(channelId);
410
+ }
411
+ loadMembersFromDb(channelId) {
412
+ try {
413
+ const rows = this.selectMembers.all(channelId);
414
+ const memberMap = this.getMemberMap(channelId);
415
+ const nameMap = this.getNameToUid(channelId);
416
+ for (const r of rows) {
417
+ if (!r.uid || !r.name)
418
+ continue;
419
+ memberMap.set(r.uid, r.name);
420
+ nameMap.set(r.name, r.uid);
421
+ }
422
+ }
423
+ catch (err) {
424
+ console.error(`group-context: loadMembersFromDb(${channelId}) failed: ${String(err)}`);
425
+ }
426
+ }
427
+ loadMessagesFromDb(channelId) {
428
+ try {
429
+ const rows = this.selectRecentMessages.all(channelId, this.maxWindowSize);
430
+ if (rows.length === 0)
431
+ return;
432
+ // Rows come in DESC order, reverse to chronological
433
+ rows.reverse();
434
+ const existing = this.messageCache.get(channelId);
435
+ if (existing && existing.length > 0)
436
+ return; // Don't overwrite live data
437
+ // Map snake_case DB columns to camelCase GroupMessage
438
+ this.messageCache.set(channelId, rows.map(r => ({
439
+ fromUid: r.from_uid,
440
+ fromName: r.from_name,
441
+ content: r.content,
442
+ timestamp: r.timestamp,
443
+ })));
444
+ }
445
+ catch (err) {
446
+ console.error(`group-context: loadMessagesFromDb(${channelId}) failed: ${String(err)}`);
447
+ }
448
+ }
449
+ /** Load all persisted members and messages from DB (call once at startup). */
450
+ loadAllFromDb() {
451
+ try {
452
+ const rows = this.adapter.prepare('SELECT DISTINCT group_id FROM group_members').all();
453
+ for (const row of rows) {
454
+ this.loadMembersFromDb(row.group_id);
455
+ this.loadMessagesFromDb(row.group_id);
456
+ }
457
+ if (rows.length > 0) {
458
+ console.log(`[group-context] Loaded members + messages for ${rows.length} group(s) from DB`);
459
+ }
460
+ }
461
+ catch (err) {
462
+ console.error(`group-context: loadAllFromDb failed: ${String(err)}`);
463
+ }
464
+ }
465
+ }
466
+ //# sourceMappingURL=group-context.js.map