@mininglamp-oss/cc-channel-octo 1.0.3-dev.203dc5f β†’ 1.0.3-dev.3332be0

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 (49) hide show
  1. package/config.bot.example.json +2 -1
  2. package/config.example.json +8 -1
  3. package/dist/agent-bridge.js +6 -1
  4. package/dist/agent-bridge.js.map +1 -1
  5. package/dist/config.d.ts +80 -0
  6. package/dist/config.js +12 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/group-config.d.ts +18 -9
  9. package/dist/group-config.js +47 -10
  10. package/dist/group-config.js.map +1 -1
  11. package/dist/group-context.d.ts +18 -0
  12. package/dist/group-context.js +86 -7
  13. package/dist/group-context.js.map +1 -1
  14. package/dist/group-md-cache.d.ts +119 -0
  15. package/dist/group-md-cache.js +172 -0
  16. package/dist/group-md-cache.js.map +1 -0
  17. package/dist/group-md-events.d.ts +75 -0
  18. package/dist/group-md-events.js +74 -0
  19. package/dist/group-md-events.js.map +1 -0
  20. package/dist/group-md-tool.d.ts +80 -0
  21. package/dist/group-md-tool.js +181 -0
  22. package/dist/group-md-tool.js.map +1 -0
  23. package/dist/group-md-writeback.d.ts +183 -0
  24. package/dist/group-md-writeback.js +223 -0
  25. package/dist/group-md-writeback.js.map +1 -0
  26. package/dist/group-md.d.ts +76 -0
  27. package/dist/group-md.js +187 -0
  28. package/dist/group-md.js.map +1 -0
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +130 -15
  31. package/dist/index.js.map +1 -1
  32. package/dist/octo/api.d.ts +157 -1
  33. package/dist/octo/api.js +189 -1
  34. package/dist/octo/api.js.map +1 -1
  35. package/dist/octo/channel-id.d.ts +36 -0
  36. package/dist/octo/channel-id.js +52 -0
  37. package/dist/octo/channel-id.js.map +1 -0
  38. package/dist/octo/types.d.ts +22 -0
  39. package/dist/octo/types.js.map +1 -1
  40. package/dist/prompt-safety.d.ts +20 -0
  41. package/dist/prompt-safety.js +24 -0
  42. package/dist/prompt-safety.js.map +1 -1
  43. package/dist/session-router.d.ts +63 -1
  44. package/dist/session-router.js +129 -3
  45. package/dist/session-router.js.map +1 -1
  46. package/dist/session-store.d.ts +26 -0
  47. package/dist/session-store.js +64 -1
  48. package/dist/session-store.js.map +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GROUP.md / THREAD.md resolution: server-first fetch with a never-lose
3
+ * local-file fallback, behind a feature flag.
4
+ *
5
+ * `resolveGroupInstructions` is the single entry point the message pipeline
6
+ * calls to obtain the trusted per-conversation instruction block injected into
7
+ * the agent's system prompt.
8
+ *
9
+ * πŸ”΄ Thread vs group are MUTUALLY EXCLUSIVE (老板拍板口径, #88 P3). The entry
10
+ * point routes on channelId shape:
11
+ * - A thread (`<groupNo>____<shortId>`, CommunityTopic) resolves its OWN
12
+ * THREAD.md only. It NEVER falls back to β€” nor stacks β€” the parent group's
13
+ * GROUP.md, and it NEVER reads or writes the group's `groupMdCache` (which
14
+ * is keyed by the parent groupNo). See `resolveThreadInstructions`.
15
+ * - A plain group (`<groupNo>`, no separator) resolves its GROUP.md exactly as
16
+ * before β€” the group branch below is byte-for-byte the pre-P3 behavior.
17
+ *
18
+ * The bug this fixes (XIN-224): the old resolver collapsed EVERY channelId to
19
+ * its parent groupNo via `extractParentGroupNo`, so with `serverMd` on a thread
20
+ * message was injected the parent group's GROUP.md β€” violating the redline that
21
+ * a thread injects only its own THREAD.md.
22
+ *
23
+ * Group-branch resolution order (unchanged):
24
+ * 1. Feature flag OFF (default) or no cache wired β†’ pure local file
25
+ * (`loadGroupConfig`). Byte-identical to the pre-P2 behavior.
26
+ * 2. Feature flag ON:
27
+ * a. serve a cached server GROUP.md if present and not past its TTL (keyed
28
+ * by the group number). The cache is IN-MEMORY ONLY (no disk), so the
29
+ * only way content reaches this trusted system-prompt channel is a
30
+ * live, authenticated fetch β€” never a forgeable on-disk artifact
31
+ * (review #172 πŸ”΄; see group-md-cache.ts);
32
+ * b. otherwise fetch from the server (server-first). On success with
33
+ * non-empty content, cache it and use it;
34
+ * c. on ANY failure (404 "no GROUP.md", network, timeout, empty content)
35
+ * fall back to the local file. The local-file path is never lost.
36
+ *
37
+ * Thread-branch resolution order (P3-1) mirrors the group branch, keyed by the
38
+ * COMPOSITE `<groupNo>::<shortId>` (see `resolveThreadInstructions`), gated on
39
+ * the independent `threadMd` flag; when the flag is off a thread still resolves
40
+ * its local `<shortId>.md` (already the correct non-stacking behavior).
41
+ *
42
+ * Trust: server content stands on its own trust root β€” an authenticated
43
+ * `getGroupMd` / `getThreadMd` over the bot token against the SSRF-validated
44
+ * `apiUrl` β€” NOT on the OS-permission trust the local `groupConfigDir` file
45
+ * relies on. By caching only in memory we never let server content masquerade
46
+ * as (or be confused with) a trusted local file on disk.
47
+ *
48
+ * Never throws β€” a server error degrades to local, a local miss degrades to
49
+ * "no custom instructions".
50
+ */
51
+ import type { GroupMdCache, ThreadMdCache } from './group-md-cache.js';
52
+ export interface ResolveGroupInstructionsParams {
53
+ /** Operator local-instruction directory (the existing fallback source). */
54
+ groupConfigDir?: string;
55
+ /** Feature flag: when false/undefined, server GROUP.md fetch is skipped. */
56
+ serverMd?: boolean;
57
+ /**
58
+ * P3-1 feature flag: when true, a THREAD channel resolves its THREAD.md
59
+ * server-first (GET /v1/bot/groups/{groupNo}/threads/{shortId}/md) before its
60
+ * local `<shortId>.md`. When false/undefined (default) a thread resolves ONLY
61
+ * its local file β€” which is already the correct non-stacking behavior, so the
62
+ * flag gates the NEW server-read capability, not the bug fix. Independent of
63
+ * `serverMd` (which governs the group server-read path).
64
+ */
65
+ threadMd?: boolean;
66
+ apiUrl: string;
67
+ botToken: string;
68
+ /** Full channelId (may be a `<groupNo>____<shortId>` thread composite). */
69
+ channelId: string;
70
+ /** Server GROUP.md cache (group branch). Omitted (or flag off) β†’ pure local. */
71
+ cache?: GroupMdCache;
72
+ /** Server THREAD.md cache (thread branch). Omitted (or flag off) β†’ pure local. */
73
+ threadCache?: ThreadMdCache;
74
+ signal?: AbortSignal;
75
+ }
76
+ export declare function resolveGroupInstructions(params: ResolveGroupInstructionsParams): Promise<string | undefined>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * GROUP.md / THREAD.md resolution: server-first fetch with a never-lose
3
+ * local-file fallback, behind a feature flag.
4
+ *
5
+ * `resolveGroupInstructions` is the single entry point the message pipeline
6
+ * calls to obtain the trusted per-conversation instruction block injected into
7
+ * the agent's system prompt.
8
+ *
9
+ * πŸ”΄ Thread vs group are MUTUALLY EXCLUSIVE (老板拍板口径, #88 P3). The entry
10
+ * point routes on channelId shape:
11
+ * - A thread (`<groupNo>____<shortId>`, CommunityTopic) resolves its OWN
12
+ * THREAD.md only. It NEVER falls back to β€” nor stacks β€” the parent group's
13
+ * GROUP.md, and it NEVER reads or writes the group's `groupMdCache` (which
14
+ * is keyed by the parent groupNo). See `resolveThreadInstructions`.
15
+ * - A plain group (`<groupNo>`, no separator) resolves its GROUP.md exactly as
16
+ * before β€” the group branch below is byte-for-byte the pre-P3 behavior.
17
+ *
18
+ * The bug this fixes (XIN-224): the old resolver collapsed EVERY channelId to
19
+ * its parent groupNo via `extractParentGroupNo`, so with `serverMd` on a thread
20
+ * message was injected the parent group's GROUP.md β€” violating the redline that
21
+ * a thread injects only its own THREAD.md.
22
+ *
23
+ * Group-branch resolution order (unchanged):
24
+ * 1. Feature flag OFF (default) or no cache wired β†’ pure local file
25
+ * (`loadGroupConfig`). Byte-identical to the pre-P2 behavior.
26
+ * 2. Feature flag ON:
27
+ * a. serve a cached server GROUP.md if present and not past its TTL (keyed
28
+ * by the group number). The cache is IN-MEMORY ONLY (no disk), so the
29
+ * only way content reaches this trusted system-prompt channel is a
30
+ * live, authenticated fetch β€” never a forgeable on-disk artifact
31
+ * (review #172 πŸ”΄; see group-md-cache.ts);
32
+ * b. otherwise fetch from the server (server-first). On success with
33
+ * non-empty content, cache it and use it;
34
+ * c. on ANY failure (404 "no GROUP.md", network, timeout, empty content)
35
+ * fall back to the local file. The local-file path is never lost.
36
+ *
37
+ * Thread-branch resolution order (P3-1) mirrors the group branch, keyed by the
38
+ * COMPOSITE `<groupNo>::<shortId>` (see `resolveThreadInstructions`), gated on
39
+ * the independent `threadMd` flag; when the flag is off a thread still resolves
40
+ * its local `<shortId>.md` (already the correct non-stacking behavior).
41
+ *
42
+ * Trust: server content stands on its own trust root β€” an authenticated
43
+ * `getGroupMd` / `getThreadMd` over the bot token against the SSRF-validated
44
+ * `apiUrl` β€” NOT on the OS-permission trust the local `groupConfigDir` file
45
+ * relies on. By caching only in memory we never let server content masquerade
46
+ * as (or be confused with) a trusted local file on disk.
47
+ *
48
+ * Never throws β€” a server error degrades to local, a local miss degrades to
49
+ * "no custom instructions".
50
+ */
51
+ import { getGroupMd, getThreadMd } from './octo/api.js';
52
+ import { extractParentGroupNo, extractThreadShortId, isThreadChannelId } from './octo/channel-id.js';
53
+ import { loadGroupConfig, MAX_GROUP_CONFIG_BYTES } from './group-config.js';
54
+ /**
55
+ * Trim and byte-bound server-provided GROUP.md the same way loadGroupConfig
56
+ * bounds a local file, so an oversized server payload can't blow the prompt
57
+ * budget. Returns undefined for empty/whitespace-only content.
58
+ */
59
+ function boundInstructions(content) {
60
+ let text = content;
61
+ if (Buffer.byteLength(text, 'utf-8') > MAX_GROUP_CONFIG_BYTES) {
62
+ const buf = Buffer.from(text, 'utf-8').subarray(0, MAX_GROUP_CONFIG_BYTES);
63
+ // The slice may end mid-codepoint; trim a trailing replacement char.
64
+ text = buf.toString('utf-8').replace(/οΏ½+$/, '') + '\n[… group config truncated]';
65
+ }
66
+ const trimmed = text.trim();
67
+ return trimmed.length > 0 ? trimmed : undefined;
68
+ }
69
+ export async function resolveGroupInstructions(params) {
70
+ // πŸ”΄ Thread and group are mutually exclusive. A thread resolves ONLY its own
71
+ // THREAD.md and must never touch the parent group's GROUP.md nor its cache.
72
+ if (isThreadChannelId(params.channelId)) {
73
+ return resolveThreadInstructions(params);
74
+ }
75
+ return resolveGroupBranch(params);
76
+ }
77
+ /**
78
+ * Plain-group branch β€” byte-for-byte the pre-P3 `resolveGroupInstructions`
79
+ * behavior (server-first GROUP.md + in-memory cache keyed by groupNo + never-
80
+ * lose local `<groupNo>.md` fallback). Reached only for non-thread channelIds.
81
+ */
82
+ async function resolveGroupBranch(params) {
83
+ const { groupConfigDir, serverMd, apiUrl, botToken, channelId, cache, signal } = params;
84
+ const local = () => loadGroupConfig(groupConfigDir, channelId);
85
+ // Flag off (or no cache to dedupe fetches) β†’ pure local file, unchanged behavior.
86
+ if (!serverMd || !cache)
87
+ return local();
88
+ const groupNo = extractParentGroupNo(channelId);
89
+ // a) fresh cached server GROUP.md wins. The cache is in-memory only and TTL-
90
+ // bounded, so an expired entry reads as a miss and falls through to a
91
+ // re-fetch below (staleness backstop; item B adds event-driven refresh).
92
+ const cached = cache.get(groupNo);
93
+ if (cached) {
94
+ const bounded = boundInstructions(cached.content);
95
+ if (bounded)
96
+ return bounded;
97
+ // Cached-but-empty (shouldn't normally happen) β†’ fall through to local.
98
+ return local();
99
+ }
100
+ // b) server-first fetch.
101
+ try {
102
+ const md = await getGroupMd({ apiUrl, botToken, groupNo, signal });
103
+ const bounded = boundInstructions(md?.content ?? '');
104
+ if (bounded) {
105
+ const entry = {
106
+ content: md.content,
107
+ version: typeof md.version === 'number' ? md.version : 0,
108
+ updated_at: md.updated_at ?? null,
109
+ updated_by: md.updated_by,
110
+ };
111
+ cache.set(groupNo, entry);
112
+ return bounded;
113
+ }
114
+ // Server reachable but no/empty GROUP.md β†’ local fallback.
115
+ return local();
116
+ }
117
+ catch (err) {
118
+ // c) 404 / network / timeout β€” never-lose local fallback.
119
+ console.error(`[cc-channel-octo] group-md: server fetch for ${groupNo} failed, falling back to local: ${String(err)}`);
120
+ return local();
121
+ }
122
+ }
123
+ /**
124
+ * Thread branch (P3-1) β€” resolves a CommunityTopic thread's OWN THREAD.md.
125
+ *
126
+ * πŸ”΄ By construction this branch NEVER reads or writes the group `cache`
127
+ * (keyed by parent groupNo) and NEVER calls `getGroupMd`, so a thread can never
128
+ * be injected β€” or have cached β€” its parent group's GROUP.md.
129
+ *
130
+ * Resolution mirrors the group branch, keyed by the COMPOSITE `groupNo::shortId`:
131
+ * - `threadMd` off (or no thread cache) β†’ local `<shortId>.md` only
132
+ * (`loadGroupConfig` is already thread-aware). This is the correct
133
+ * non-stacking behavior even with the new server capability disabled.
134
+ * - `threadMd` on:
135
+ * a. serve a fresh cached THREAD.md (in-memory only, TTL-bounded);
136
+ * b. else server-first `getThreadMd`, cache non-empty content and use it;
137
+ * c. on ANY failure / empty β†’ never-lose local `<shortId>.md` fallback.
138
+ */
139
+ async function resolveThreadInstructions(params) {
140
+ const { groupConfigDir, threadMd, apiUrl, botToken, channelId, threadCache, signal } = params;
141
+ // Local fallback keeps the FULL channelId β€” loadGroupConfig routes a thread to
142
+ // its own `<shortId>.md`, never the parent group's `<groupNo>.md`.
143
+ const local = () => loadGroupConfig(groupConfigDir, channelId);
144
+ // Flag off (or no thread cache) β†’ pure local `<shortId>.md`. Already correct
145
+ // non-stacking behavior β€” the flag only gates the server-read capability.
146
+ if (!threadMd || !threadCache)
147
+ return local();
148
+ const groupNo = extractParentGroupNo(channelId);
149
+ const shortId = extractThreadShortId(channelId);
150
+ // A malformed thread channelId (`<groupNo>____` with no shortId) can't be
151
+ // server-keyed β€” degrade to local rather than fetch a group-scoped path.
152
+ if (!shortId)
153
+ return local();
154
+ // a) fresh cached server THREAD.md wins. Composite-keyed, in-memory only,
155
+ // TTL-bounded (expired β†’ miss β†’ re-fetch below).
156
+ const cached = threadCache.get(groupNo, shortId);
157
+ if (cached) {
158
+ const bounded = boundInstructions(cached.content);
159
+ if (bounded)
160
+ return bounded;
161
+ // Cached-but-empty (shouldn't normally happen) β†’ fall through to local.
162
+ return local();
163
+ }
164
+ // b) server-first fetch of this thread's own THREAD.md.
165
+ try {
166
+ const md = await getThreadMd({ apiUrl, botToken, groupNo, shortId, signal });
167
+ const bounded = boundInstructions(md?.content ?? '');
168
+ if (bounded) {
169
+ const entry = {
170
+ content: md.content,
171
+ version: typeof md.version === 'number' ? md.version : 0,
172
+ updated_at: md.updated_at ?? null,
173
+ updated_by: md.updated_by,
174
+ };
175
+ threadCache.set(groupNo, shortId, entry);
176
+ return bounded;
177
+ }
178
+ // Server reachable but no/empty THREAD.md β†’ local fallback.
179
+ return local();
180
+ }
181
+ catch (err) {
182
+ // c) 404 / network / timeout β€” never-lose local fallback.
183
+ console.error(`[cc-channel-octo] thread-md: server fetch for ${groupNo}::${shortId} failed, falling back to local: ${String(err)}`);
184
+ return local();
185
+ }
186
+ }
187
+ //# sourceMappingURL=group-md.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"group-md.js","sourceRoot":"","sources":["../src/group-md.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ import { SessionRouter } from './session-router.js';
13
13
  import { GroupContext } from './group-context.js';
14
14
  import { StreamRelay } from './stream-relay.js';
15
15
  import type { BotMessage } from './octo/types.js';
16
+ import { GroupMdCache, ThreadMdCache } from './group-md-cache.js';
17
+ import { GroupMdWriteback, ThreadMdWriteback } from './group-md-writeback.js';
16
18
  import { CronStore } from './cron-store.js';
17
19
  /**
18
20
  * Resolve a single bot's concrete Config by its configId (config.json
@@ -33,7 +35,7 @@ export type BotStack = ManagedBot;
33
35
  * agent query β†’ stream β†’ persist. Exported so tests can drive the real pipeline
34
36
  * (not a replica) β€” `main()` is the only production caller.
35
37
  */
36
- export declare function handleMessage(msg: BotMessage, config: ReturnType<typeof loadConfig>, store: SessionStore, router: SessionRouter, groupContext: GroupContext, streamRelay: StreamRelay, botId: string, cronStore?: CronStore): Promise<void>;
38
+ export declare function handleMessage(msg: BotMessage, config: ReturnType<typeof loadConfig>, store: SessionStore, router: SessionRouter, groupContext: GroupContext, streamRelay: StreamRelay, botId: string, cronStore?: CronStore, groupMdCache?: GroupMdCache, groupMdWriteback?: GroupMdWriteback, threadMdCache?: ThreadMdCache, threadMdWriteback?: ThreadMdWriteback): Promise<void>;
37
39
  /** Max length of the rendered tool-params string in a πŸ”§ progress notice. */
38
40
  export declare const MAX_TOOL_PARAM_CHARS = 120;
39
41
  /**
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { OctoGateway } from './gateway.js';
15
15
  import { SessionRouter } from './session-router.js';
16
16
  import { GroupContext } from './group-context.js';
17
17
  import { queryAgent } from './agent-bridge.js';
18
- import { sanitizeDisplayName, escapeSectionMarkers, sanitizePromptBody } from './prompt-safety.js';
18
+ import { sanitizeDisplayName, escapeSectionMarkers, sanitizePromptBody, formatSenderLabel } from './prompt-safety.js';
19
19
  import { cleanupExpiredCwds, resolveMemoryDir, resolveSessionCwd } from './cwd-resolver.js';
20
20
  import { StreamRelay } from './stream-relay.js';
21
21
  import { sendMessage, sendReadReceipt, getChannelMessages, getUploadCredentials } from './octo/api.js';
@@ -23,10 +23,14 @@ import { ChannelType, MessageType } from './octo/types.js';
23
23
  import { resolveContent, tryResolveFile, resolveHistoricalMessagePlaceholder } from './inbound.js';
24
24
  import { downloadInboundImage, MAX_IMAGES_PER_MESSAGE } from './media-inbound.js';
25
25
  import { handleCommand } from './commands.js';
26
- import { loadGroupConfig } from './group-config.js';
26
+ import { resolveGroupInstructions } from './group-md.js';
27
+ import { GroupMdCache, ThreadMdCache, DEFAULT_GROUP_MD_TTL_MS } from './group-md-cache.js';
28
+ import { GroupMdWriteback, ThreadMdWriteback } from './group-md-writeback.js';
27
29
  import { CronStore } from './cron-store.js';
28
30
  import { CronScheduler } from './cron-scheduler.js';
29
31
  import { createCronToolServer, CRON_TOOL_SERVER_NAME } from './cron-tool.js';
32
+ import { createGroupMdToolServer, GROUP_MD_TOOL_SERVER_NAME, createThreadMdToolServer, THREAD_MD_TOOL_SERVER_NAME, } from './group-md-tool.js';
33
+ import { isThreadChannelId } from './octo/channel-id.js';
30
34
  import { buildInlinedFileBody, truncateUtf8ByBytes, assembleUserMessage, MAX_USER_LLM_BYTES } from './file-inline-wrap.js';
31
35
  import { join } from 'node:path';
32
36
  import { mkdirSync, realpathSync } from 'node:fs';
@@ -177,6 +181,34 @@ async function startBot(config, multi) {
177
181
  // --- Group context ---
178
182
  const groupContext = new GroupContext(adapter, config.context.maxContextChars);
179
183
  groupContext.loadAllFromDb();
184
+ // --- P2-A: server GROUP.md cache. IN-MEMORY ONLY (no disk) β€” the resolved
185
+ // GROUP.md is injected as a TRUSTED system-prompt block, and a disk cache the
186
+ // gateway user can write would be a chat-injection β†’ trusted-prompt poisoning
187
+ // vector that survives restart (review #172 πŸ”΄; see group-md-cache.ts). TTL
188
+ // bounds staleness (review #172 🟑). Decoupled from the P1 GroupContext
189
+ // caches; created unconditionally (cheap, only populated when serverMd is on). ---
190
+ const groupMdCache = new GroupMdCache(config.serverMdTtlMs ?? DEFAULT_GROUP_MD_TTL_MS);
191
+ // --- P3-1: server THREAD.md cache. Same IN-MEMORY-ONLY security model as the
192
+ // group cache above, but keyed by the COMPOSITE `groupNo::shortId` so one
193
+ // group's threads never collide (defensive even though shortId is a globally
194
+ // unique snowflake today). Created unconditionally (cheap; only populated when
195
+ // config.threadMd is on). Kept SEPARATE from groupMdCache so the thread branch
196
+ // can never read or write a parent-group GROUP.md entry (老板拍板互ζ–₯口径). ---
197
+ const threadMdCache = new ThreadMdCache(config.serverMdTtlMs ?? DEFAULT_GROUP_MD_TTL_MS);
198
+ // --- P2-C: GROUP.md write-back coordinator. Shared across turns so its
199
+ // per-groupNo write lock actually serializes concurrent agent turns writing
200
+ // the same group (a per-turn instance would defeat the lock). Updates the
201
+ // SAME in-memory groupMdCache above on a successful PUT β€” no new disk surface.
202
+ // Cheap to construct unconditionally; only exercised when the write-back tool
203
+ // is registered (config.mdWriteback). ---
204
+ const groupMdWriteback = new GroupMdWriteback(groupMdCache);
205
+ // --- P3-2: THREAD.md write-back coordinator. Thread analogue of
206
+ // groupMdWriteback, shared across turns so its per-`groupNo::shortId` write
207
+ // lock serializes concurrent turns writing the same thread. Updates the SAME
208
+ // in-memory threadMdCache above on a successful PUT (no disk surface). Cheap
209
+ // to construct unconditionally; only exercised when the thread write-back
210
+ // tool is registered (config.mdWriteback on a thread channel). ---
211
+ const threadMdWriteback = new ThreadMdWriteback(threadMdCache);
180
212
  // --- #115: cron (opt-in). ---
181
213
  if (config.sdk.cron && config.botId) {
182
214
  cronStore = new CronStore(join(config.baseDir, config.botId, 'cron.json'));
@@ -223,7 +255,11 @@ async function startBot(config, multi) {
223
255
  console.warn(`[cc-channel-octo] ${label}Could not prefetch media CDN host (inbound media limited to apiUrl host): ${err instanceof Error ? err.message : String(err)}`);
224
256
  }
225
257
  // --- Session router ---
226
- const router = new SessionRouter(config, gateway.botId, gateway.ownerUid);
258
+ // P2-B / P3-2: the router holds both md caches so a server GROUP.md or
259
+ // THREAD.md change event invalidates the affected entry (re-fetched
260
+ // authoritatively next turn β€” never trusting the event payload). No-op when
261
+ // the corresponding flag (serverMd / threadMd) is off.
262
+ const router = new SessionRouter(config, gateway.botId, gateway.ownerUid, groupMdCache, threadMdCache);
227
263
  // --- Active handler tracking (Q6: in-flight drain on shutdown) ---
228
264
  const activeHandlers = new Set();
229
265
  // Install the message handler NOW (before the socket opens). The socket is
@@ -238,7 +274,7 @@ async function startBot(config, multi) {
238
274
  // group message could be cached into group context as un-processed chatter).
239
275
  if (msg.from_uid === gateway.botId)
240
276
  return;
241
- const p = handleMessage(msg, config, store, router, groupContext, streamRelay, gateway.botId, cronStore)
277
+ const p = handleMessage(msg, config, store, router, groupContext, streamRelay, gateway.botId, cronStore, groupMdCache, groupMdWriteback, threadMdCache, threadMdWriteback)
242
278
  .catch((err) => {
243
279
  console.error(`[cc-channel-octo] ${label}Unhandled message handler error:`, err instanceof Error ? err.message : err);
244
280
  })
@@ -259,7 +295,7 @@ async function startBot(config, multi) {
259
295
  onFire: (msg) => {
260
296
  if (gateway.draining)
261
297
  return Promise.resolve();
262
- const p = handleMessage(msg, config, store, router, groupContext, streamRelay, gateway.botId, cronStore)
298
+ const p = handleMessage(msg, config, store, router, groupContext, streamRelay, gateway.botId, cronStore, groupMdCache, groupMdWriteback, threadMdCache, threadMdWriteback)
263
299
  .finally(() => { activeHandlers.delete(p); });
264
300
  activeHandlers.add(p);
265
301
  return p;
@@ -307,7 +343,7 @@ async function startBot(config, multi) {
307
343
  * agent query β†’ stream β†’ persist. Exported so tests can drive the real pipeline
308
344
  * (not a replica) β€” `main()` is the only production caller.
309
345
  */
310
- export async function handleMessage(msg, config, store, router, groupContext, streamRelay, botId, cronStore) {
346
+ export async function handleMessage(msg, config, store, router, groupContext, streamRelay, botId, cronStore, groupMdCache, groupMdWriteback, threadMdCache, threadMdWriteback) {
311
347
  const channelId = msg.channel_id ?? '';
312
348
  const channelType = msg.channel_type ?? ChannelType.DM;
313
349
  const isGroup = channelType === ChannelType.Group || channelType === ChannelType.CommunityTopic;
@@ -546,10 +582,30 @@ export async function handleMessage(msg, config, store, router, groupContext, st
546
582
  // persists the raw user content without the quote prefix to avoid prefix
547
583
  // duplication on conversation replay.
548
584
  //
549
- // The final user message is assembled AFTER history is built (below), so
550
- // the one-time history block + group-context delta can be prepended and
551
- // the whole payload capped together. See the assembly near queryAgent.
552
- const userBody = quotePrefix + bodyText;
585
+ // Sender-identity prefix (groups only): the `[Current message β€” respond to
586
+ // this ONLY]` anchor previously carried an anonymous body, so a shared-group
587
+ // Claude Code / Claw agent could not tell WHICH member sent the request
588
+ // being replied to (the anchor block itself has no sender field, unlike
589
+ // `[Recent group messages]` where each line already prefixes `name(uid):`).
590
+ // We prepend `name(uid):` β€” sanitized via formatSenderLabel β€” to align the
591
+ // current-message identity semantics with the historical block, so the
592
+ // agent has a single, uniform convention for identifying speakers across
593
+ // the whole prompt. DM channels stay anonymous: there's exactly one human
594
+ // party, adding the prefix would be pure noise. The prefix is persisted
595
+ // via appendUser below too β€” otherwise a session-replay would drop identity
596
+ // from history while keeping it on current, defeating the whole point.
597
+ //
598
+ // Resolve the displayName via GroupContext so the wire's from_name (which
599
+ // is optional and, when present, sometimes just echoes the uid) is
600
+ // upgraded to the roster displayName learned via refreshMembers. Without
601
+ // this, human users whose wire payload lacks from_name render as
602
+ // `uid:body` instead of `name(uid):body`, breaking the promised uniform
603
+ // shape.
604
+ const resolvedName = isGroup && msg.channel_id
605
+ ? groupContext.resolveDisplayName(msg.channel_id, msg.from_uid, msg.from_name)
606
+ : undefined;
607
+ const senderPrefix = isGroup ? `${formatSenderLabel(msg.from_uid, resolvedName)}:` : '';
608
+ const userBody = quotePrefix + senderPrefix + bodyText;
553
609
  // G4: Backfill history from API when local cache is empty for groups.
554
610
  // Only triggered on first interaction with a group (cold start) to avoid
555
611
  // duplicate API calls; checked via a sentinel marker stored in-memory.
@@ -710,11 +766,35 @@ export async function handleMessage(msg, config, store, router, groupContext, st
710
766
  const memoryDir = resolveMemoryDir(memBase, sessionCtx);
711
767
  sessionOpts = { ...(sessionOpts ?? {}), memoryDir };
712
768
  }
713
- // v1.0 GROUP.md: inject operator-provided per-group instructions (from
714
- // config.groupConfigDir/<channelId>.md) into the system prompt. Only for
715
- // groups β€” DMs key on the peer uid, not a shared channel.
769
+ // External MCP servers declared in config (sdk.mcpServers): seed the map
770
+ // FIRST so the per-turn in-process servers below (cron, GROUP.md write-back)
771
+ // merge on top and win any name clash β€” those are bound to this turn's
772
+ // session coords and must not be shadowed by a same-named external server.
773
+ if (config.sdk.mcpServers && Object.keys(config.sdk.mcpServers).length > 0) {
774
+ sessionOpts = {
775
+ ...(sessionOpts ?? {}),
776
+ mcpServers: { ...(sessionOpts?.mcpServers ?? {}), ...config.sdk.mcpServers },
777
+ };
778
+ }
779
+ // GROUP.md: inject operator-provided per-group instructions into the
780
+ // system prompt. Only for groups β€” DMs key on the peer uid, not a shared
781
+ // channel. P2-A: a plain group resolves GROUP.md server-first (GET
782
+ // /v1/bot/groups/{groupNo}/md, cached) with a never-lose fallback to the
783
+ // local `groupConfigDir/<channelId>.md` file (gated behind `config.serverMd`).
784
+ // P3-1: a thread (CommunityTopic) resolves its OWN THREAD.md ONLY β€” the
785
+ // resolver routes on channelId shape and NEVER stacks the parent group's
786
+ // GROUP.md for a thread (server thread read gated behind `config.threadMd`).
716
787
  if (isGroup) {
717
- const groupInstructions = loadGroupConfig(config.groupConfigDir, channelId);
788
+ const groupInstructions = await resolveGroupInstructions({
789
+ groupConfigDir: config.groupConfigDir,
790
+ serverMd: config.serverMd,
791
+ threadMd: config.threadMd,
792
+ apiUrl: config.apiUrl,
793
+ botToken: config.botToken,
794
+ channelId,
795
+ cache: groupMdCache,
796
+ threadCache: threadMdCache,
797
+ });
718
798
  if (groupInstructions) {
719
799
  sessionOpts = { ...(sessionOpts ?? {}), groupInstructions };
720
800
  }
@@ -732,9 +812,44 @@ export async function handleMessage(msg, config, store, router, groupContext, st
732
812
  const cronServer = createCronToolServer(cronStore, coords, router.getOwnerUid());
733
813
  sessionOpts = {
734
814
  ...(sessionOpts ?? {}),
735
- mcpServers: { [CRON_TOOL_SERVER_NAME]: cronServer },
815
+ mcpServers: { ...(sessionOpts?.mcpServers ?? {}), [CRON_TOOL_SERVER_NAME]: cronServer },
736
816
  };
737
817
  }
818
+ // P2-C / P3-2: when md write-back is on, inject the write-back MCP tool
819
+ // bound to THIS message's channel coords and gated to the bot owner uid.
820
+ // Only for group-like channels β€” a DM has no shared md. The tool is chosen
821
+ // by channel SHAPE and the two are MUTUALLY EXCLUSIVE (#88 P3 redline):
822
+ // - a THREAD (CommunityTopic composite `<groupNo>____<shortId>`) gets the
823
+ // thread tool, which writes the thread's OWN THREAD.md
824
+ // (PUT /threads/{shortId}/md) β€” never the parent group's GROUP.md. This
825
+ // is the XIN-230 follow-up: the write side now matches the P3-1 read
826
+ // side instead of collapsing a thread to its parent groupNo.
827
+ // - a plain GROUP gets the GROUP.md tool, unchanged (P2-C).
828
+ // Each borrows its shared writeback coordinator so the per-key write lock
829
+ // and the cache it refreshes are process-wide, not per-turn.
830
+ if (config.mdWriteback && isGroup) {
831
+ const coords = {
832
+ channelId,
833
+ fromUid: msg.from_uid,
834
+ fromName: msg.from_name,
835
+ };
836
+ if (isThreadChannelId(channelId)) {
837
+ if (threadMdWriteback) {
838
+ const threadMdServer = createThreadMdToolServer({ writeback: threadMdWriteback, apiUrl: config.apiUrl, botToken: config.botToken }, coords, router.getOwnerUid());
839
+ sessionOpts = {
840
+ ...(sessionOpts ?? {}),
841
+ mcpServers: { ...(sessionOpts?.mcpServers ?? {}), [THREAD_MD_TOOL_SERVER_NAME]: threadMdServer },
842
+ };
843
+ }
844
+ }
845
+ else if (groupMdWriteback) {
846
+ const groupMdServer = createGroupMdToolServer({ writeback: groupMdWriteback, apiUrl: config.apiUrl, botToken: config.botToken }, coords, router.getOwnerUid());
847
+ sessionOpts = {
848
+ ...(sessionOpts ?? {}),
849
+ mcpServers: { ...(sessionOpts?.mcpServers ?? {}), [GROUP_MD_TOOL_SERVER_NAME]: groupMdServer },
850
+ };
851
+ }
852
+ }
738
853
  const rawChunks = queryAgent(userContentForLLM, config, sessionCtx, onToolUse, sessionOpts);
739
854
  // Tee the generator: collect full text while streaming to Octo
740
855
  const collected = [];