polygram 0.8.0-rc.47 → 0.8.0-rc.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/config.example.json +9 -1
- package/lib/agent-loader.js +32 -6
- package/lib/session-key.js +69 -1
- package/package.json +1 -1
- package/polygram.js +72 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.8.0-rc.
|
|
4
|
+
"version": "0.8.0-rc.48",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/config.example.json
CHANGED
|
@@ -98,9 +98,17 @@
|
|
|
98
98
|
"cwd": "/Users/you/admin-agent",
|
|
99
99
|
"requireMention": true,
|
|
100
100
|
"isolateTopics": true,
|
|
101
|
+
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
101
102
|
"topics": {
|
|
102
103
|
"100": "Customer A",
|
|
103
|
-
"200":
|
|
104
|
+
"200": {
|
|
105
|
+
"name": "Customer B",
|
|
106
|
+
"agent": "customer-b-helper",
|
|
107
|
+
"cwd": "/Users/you/customer-b-projects",
|
|
108
|
+
"model": "opus",
|
|
109
|
+
"effort": "high",
|
|
110
|
+
"permissionMode": "default"
|
|
111
|
+
}
|
|
104
112
|
}
|
|
105
113
|
},
|
|
106
114
|
|
package/lib/agent-loader.js
CHANGED
|
@@ -283,15 +283,27 @@ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger =
|
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
285
|
* Compose a chat's final SdkOptions from defaults + agent + per-chat
|
|
286
|
-
* overrides. Precedence
|
|
286
|
+
* overrides + per-topic overrides. Precedence (highest to lowest):
|
|
287
|
+
* topicConfig > chatConfig > agent.raw > defaults
|
|
288
|
+
*
|
|
289
|
+
* rc.48 added the per-topic layer (`topicConfig` arg). Per-topic
|
|
290
|
+
* overrides are the principal rc.48 use case — typically loosening
|
|
291
|
+
* an agent's `bypassPermissions` default to `default` for a sensitive
|
|
292
|
+
* topic (so canUseTool prompts fire there) while keeping the rest of
|
|
293
|
+
* the chat in bypass mode. Per-topic permissionMode MUST override the
|
|
294
|
+
* chat-level one for this to work.
|
|
287
295
|
*
|
|
288
296
|
* @param {object} chatConfig — config.chats[chatId].
|
|
289
297
|
* @param {AgentBundle|null} agentBundle — null if chat has no agent.
|
|
290
298
|
* @param {object} defaults — config.defaults.
|
|
299
|
+
* @param {object} [topicConfig] — per-topic overrides from
|
|
300
|
+
* getTopicConfig(chatConfig, threadId). Empty object when there's
|
|
301
|
+
* no active topic, no override config, or topic uses legacy string
|
|
302
|
+
* form. Highest precedence — overrides chatConfig.
|
|
291
303
|
*
|
|
292
304
|
* @returns {object} SdkOptions for `query({ options: ... })`.
|
|
293
305
|
*/
|
|
294
|
-
function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
|
|
306
|
+
function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}, topicConfig = {}) {
|
|
295
307
|
// Start with defaults — these are the lowest-priority.
|
|
296
308
|
const opts = { ...defaults };
|
|
297
309
|
|
|
@@ -302,16 +314,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
|
|
|
302
314
|
if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
|
|
303
315
|
opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
|
|
304
316
|
}
|
|
305
|
-
// Agent-level model/effort/etc — only if chatConfig
|
|
306
|
-
// override.
|
|
317
|
+
// Agent-level model/effort/etc — only if chatConfig AND
|
|
318
|
+
// topicConfig don't override.
|
|
307
319
|
for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
|
|
308
|
-
if (agentBundle.raw?.[key] != null
|
|
320
|
+
if (agentBundle.raw?.[key] != null
|
|
321
|
+
&& chatConfig[key] == null
|
|
322
|
+
&& topicConfig?.[key] == null) {
|
|
309
323
|
opts[key] = agentBundle.raw[key];
|
|
310
324
|
}
|
|
311
325
|
}
|
|
312
326
|
}
|
|
313
327
|
|
|
314
|
-
// Chat-level overrides
|
|
328
|
+
// Chat-level overrides.
|
|
315
329
|
for (const [k, v] of Object.entries(chatConfig)) {
|
|
316
330
|
if (v == null) continue;
|
|
317
331
|
// Don't override the spread system-prompt with `agent` config
|
|
@@ -320,6 +334,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
|
|
|
320
334
|
opts[k] = v;
|
|
321
335
|
}
|
|
322
336
|
|
|
337
|
+
// rc.48: per-topic overrides (highest priority). Same `agent` exclusion
|
|
338
|
+
// — `agent` here is a polygram name reference, NOT an SdkOptions
|
|
339
|
+
// field. polygram's spawn flow resolves topicConfig.agent into the
|
|
340
|
+
// correct agentBundle BEFORE calling composeSdkOptions, so by the
|
|
341
|
+
// time we get here, agentBundle already reflects the topic's agent
|
|
342
|
+
// choice and the `agent` string itself shouldn't leak into opts.
|
|
343
|
+
for (const [k, v] of Object.entries(topicConfig || {})) {
|
|
344
|
+
if (v == null) continue;
|
|
345
|
+
if (k === 'agent') continue;
|
|
346
|
+
opts[k] = v;
|
|
347
|
+
}
|
|
348
|
+
|
|
323
349
|
return opts;
|
|
324
350
|
}
|
|
325
351
|
|
package/lib/session-key.js
CHANGED
|
@@ -16,8 +16,25 @@
|
|
|
16
16
|
* topic's conversation can't bleed into Billing topic's memory. This
|
|
17
17
|
* matches OpenClaw's model and is the right call when topics represent
|
|
18
18
|
* genuinely separate projects.
|
|
19
|
+
*
|
|
20
|
+
* rc.48: per-topic config overrides. `topics[threadId]` can be either a
|
|
21
|
+
* string (legacy: just a label) or an object with optional fields:
|
|
22
|
+
* { name, agent, cwd, model, effort, permissionMode }
|
|
23
|
+
* Object form lets a topic override chat-level config — typically used
|
|
24
|
+
* to scope a single topic to a different agent (e.g. music-curation),
|
|
25
|
+
* a different working dir, or to switch from `bypassPermissions` to
|
|
26
|
+
* `default` so canUseTool prompts fire for sensitive operations in
|
|
27
|
+
* that topic only. See getTopicConfig.
|
|
28
|
+
*
|
|
29
|
+
* Per-topic overrides only take effect when isolateTopics: true (each
|
|
30
|
+
* topic has its own SDK Query). With isolateTopics: false all topics
|
|
31
|
+
* share one Query; the Query's options are fixed at first-spawn time.
|
|
32
|
+
* polygram emits a one-time startup warning if topic overrides are
|
|
33
|
+
* configured on a non-isolating chat.
|
|
19
34
|
*/
|
|
20
35
|
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
21
38
|
function getSessionKey(chatId, threadId, chatConfig) {
|
|
22
39
|
const isolate = chatConfig?.isolateTopics === true;
|
|
23
40
|
if (threadId && isolate) return `${chatId}:${threadId}`;
|
|
@@ -44,4 +61,55 @@ function getThreadIdFromKey(sessionKey) {
|
|
|
44
61
|
return thread || null;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the human-readable name for a topic. Handles both the legacy
|
|
66
|
+
* string form (`topics["100"] = "Orders"`) and the rc.48 object form
|
|
67
|
+
* (`topics["200"] = { name: "Music", agent: "music-curation" }`). Falls
|
|
68
|
+
* back to the threadId itself if the topic has no name field.
|
|
69
|
+
*/
|
|
70
|
+
function getTopicName(chatConfig, threadId) {
|
|
71
|
+
if (threadId == null || threadId === '') return null;
|
|
72
|
+
const t = chatConfig?.topics?.[threadId];
|
|
73
|
+
if (typeof t === 'string') return t;
|
|
74
|
+
if (t && typeof t === 'object' && typeof t.name === 'string' && t.name) {
|
|
75
|
+
return t.name;
|
|
76
|
+
}
|
|
77
|
+
return String(threadId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* rc.48: extract per-topic SdkOptions overrides. Returns the topic
|
|
82
|
+
* entry's overridable fields (model, effort, cwd, agent,
|
|
83
|
+
* permissionMode), excluding `name` (which is a polygram label, not
|
|
84
|
+
* an SdkOptions field — see getTopicName).
|
|
85
|
+
*
|
|
86
|
+
* Returns `{}` for:
|
|
87
|
+
* - missing threadId
|
|
88
|
+
* - missing chatConfig.topics
|
|
89
|
+
* - threadId not present in topics
|
|
90
|
+
* - legacy string topic entry (label-only, no overrides)
|
|
91
|
+
* - object with only `name`
|
|
92
|
+
*
|
|
93
|
+
* Callers (composeSdkOptions, polygram's spawn flow) merge this on
|
|
94
|
+
* top of chat-level config. Per-topic overrides take HIGHEST
|
|
95
|
+
* precedence — including overriding the chat-level permissionMode,
|
|
96
|
+
* which is the principal rc.48 use case (loosen one topic from the
|
|
97
|
+
* agent's `bypassPermissions` default to `default`).
|
|
98
|
+
*/
|
|
99
|
+
function getTopicConfig(chatConfig, threadId) {
|
|
100
|
+
if (threadId == null || threadId === '') return {};
|
|
101
|
+
const t = chatConfig?.topics?.[threadId];
|
|
102
|
+
if (!t || typeof t !== 'object') return {};
|
|
103
|
+
// Strip `name` — that's the label, not an override. Everything else
|
|
104
|
+
// in the entry is an override candidate.
|
|
105
|
+
const { name, ...overrides } = t;
|
|
106
|
+
return overrides;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
getSessionKey,
|
|
111
|
+
getChatIdFromKey,
|
|
112
|
+
getThreadIdFromKey,
|
|
113
|
+
getTopicName,
|
|
114
|
+
getTopicConfig,
|
|
115
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.8.0-rc.
|
|
3
|
+
"version": "0.8.0-rc.48",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc-client.js",
|
|
6
6
|
"bin": {
|
package/polygram.js
CHANGED
|
@@ -195,12 +195,13 @@ function isWellFormedMessage(msg) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// ─── Session key — moved to lib/session-key.js so tests can import it. ─
|
|
198
|
-
const {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
const {
|
|
199
|
+
getSessionKey,
|
|
200
|
+
getChatIdFromKey,
|
|
201
|
+
getThreadIdFromKey,
|
|
202
|
+
getTopicName,
|
|
203
|
+
getTopicConfig,
|
|
204
|
+
} = require('./lib/session-key');
|
|
204
205
|
|
|
205
206
|
function getSessionLabel(chatConfig, threadId) {
|
|
206
207
|
const topic = getTopicName(chatConfig, threadId);
|
|
@@ -826,30 +827,52 @@ function spawnClaude(sessionKey, ctx) {
|
|
|
826
827
|
* mcpServers, optional resume sessionId for continuity.
|
|
827
828
|
*/
|
|
828
829
|
function buildSdkOptions(sessionKey, ctx) {
|
|
829
|
-
const { chatConfig, existingSessionId, label, chatId } = ctx;
|
|
830
|
+
const { chatConfig, existingSessionId, label, chatId, threadId } = ctx;
|
|
831
|
+
|
|
832
|
+
// rc.48: per-topic config overrides. When `topics[threadId]` is an
|
|
833
|
+
// object (not a legacy string label), these fields take precedence
|
|
834
|
+
// over chat-level config. Principal use case: scope a single topic
|
|
835
|
+
// to a different agent, cwd, or permissionMode (e.g. flip music
|
|
836
|
+
// topic from `bypassPermissions` to `default` so settings.json
|
|
837
|
+
// permissions.allow gates apply).
|
|
838
|
+
//
|
|
839
|
+
// Per-topic overrides only apply meaningfully when isolateTopics is
|
|
840
|
+
// true — each topic has its own SDK Query. With isolateTopics: false
|
|
841
|
+
// all topics share one Query whose options are fixed at first-spawn.
|
|
842
|
+
// Startup validation (validateTopicConfigs) emits a one-time warning
|
|
843
|
+
// when a chat has topic overrides + isolateTopics: false.
|
|
844
|
+
const topicConfig = getTopicConfig(chatConfig, threadId);
|
|
845
|
+
const effectiveAgent = topicConfig.agent || chatConfig.agent;
|
|
830
846
|
|
|
831
847
|
// Per-chat agent (D14): if pinned, load & compose. Failure is
|
|
832
848
|
// non-fatal — chat falls back to defaults; logged for ops.
|
|
833
849
|
let agentBundle = null;
|
|
834
|
-
if (
|
|
850
|
+
if (effectiveAgent) {
|
|
835
851
|
try {
|
|
836
|
-
agentBundle = agentLoader.loadAgent(
|
|
852
|
+
agentBundle = agentLoader.loadAgent(effectiveAgent, {
|
|
837
853
|
homeDir: CHILD_HOME,
|
|
838
854
|
// Pass cwd so the loader checks Claude Code's project-level
|
|
839
855
|
// path (`<cwd>/.claude/agents/<name>.md`) before the
|
|
840
|
-
// user-level path or polygram's directory convention.
|
|
841
|
-
cwd
|
|
856
|
+
// user-level path or polygram's directory convention. rc.48:
|
|
857
|
+
// topic-level cwd takes precedence so topic-scoped agents
|
|
858
|
+
// load from the topic's project dir.
|
|
859
|
+
cwd: topicConfig.cwd || chatConfig.cwd,
|
|
842
860
|
logger: console,
|
|
843
861
|
});
|
|
844
862
|
} catch (err) {
|
|
845
863
|
console.error(`[${label}] agent-loader: ${err.message}`);
|
|
846
864
|
logEvent('agent-load-failed', {
|
|
847
|
-
chat_id: chatId, agent:
|
|
865
|
+
chat_id: chatId, agent: effectiveAgent, error: err.message,
|
|
866
|
+
topic: threadId || null,
|
|
848
867
|
});
|
|
849
868
|
}
|
|
850
869
|
}
|
|
851
870
|
|
|
852
|
-
|
|
871
|
+
const effectiveModel = topicConfig.model || chatConfig.model;
|
|
872
|
+
const effectiveEffort = topicConfig.effort || chatConfig.effort;
|
|
873
|
+
const agentSuffix = effectiveAgent && effectiveAgent !== chatConfig.agent
|
|
874
|
+
? ` agent=${effectiveAgent}` : '';
|
|
875
|
+
console.log(`[${label}] Spawning SDK Query (${effectiveModel}/${effectiveEffort}${agentSuffix})`);
|
|
853
876
|
|
|
854
877
|
// Env: SHADOW semantics (gate 33) — must enumerate every var
|
|
855
878
|
// pollygram needs in the spawned worker.
|
|
@@ -916,10 +939,11 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
916
939
|
}),
|
|
917
940
|
};
|
|
918
941
|
|
|
919
|
-
// Compose with agent overlay + chat-level config
|
|
920
|
-
// precedence: chatConfig > agent > defaults.
|
|
921
|
-
// we care about for SDK options are
|
|
922
|
-
// others (agent, chrome, isolateTopics)
|
|
942
|
+
// Compose with agent overlay + chat-level config + per-topic overrides.
|
|
943
|
+
// agent-loader precedence: topicConfig > chatConfig > agent > defaults.
|
|
944
|
+
// The chatConfig keys we care about for SDK options are
|
|
945
|
+
// model/effort/cwd/thinking; others (agent, chrome, isolateTopics)
|
|
946
|
+
// are polygram-only and stripped by composeSdkOptions.
|
|
923
947
|
const composed = agentLoader.composeSdkOptions(
|
|
924
948
|
{
|
|
925
949
|
// chat-level overrides — only the keys SDK understands.
|
|
@@ -930,8 +954,19 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
930
954
|
},
|
|
931
955
|
agentBundle,
|
|
932
956
|
baseOpts,
|
|
957
|
+
topicConfig, // rc.48: highest precedence — overrides chat-level fields.
|
|
933
958
|
);
|
|
934
959
|
|
|
960
|
+
// rc.48: keep permissionMode + allowDangerouslySkipPermissions
|
|
961
|
+
// consistent. baseOpts sets allowDangerouslySkipPermissions=true when
|
|
962
|
+
// permissionMode='bypassPermissions'. If a topic flipped permissionMode
|
|
963
|
+
// to anything else (typically 'default' to gate via settings.json),
|
|
964
|
+
// also disable allowDangerouslySkipPermissions so the SDK actually
|
|
965
|
+
// honours the permission gates instead of skipping them.
|
|
966
|
+
if (composed.permissionMode && composed.permissionMode !== 'bypassPermissions') {
|
|
967
|
+
composed.allowDangerouslySkipPermissions = false;
|
|
968
|
+
}
|
|
969
|
+
|
|
935
970
|
// Append polygram's display constraints to the systemPrompt.
|
|
936
971
|
// Infrastructure-layer hint — the agent's own prompt covers
|
|
937
972
|
// business logic; polygram adds "your output renders in Telegram,
|
|
@@ -3795,6 +3830,26 @@ async function main() {
|
|
|
3795
3830
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|
|
3796
3831
|
console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
|
|
3797
3832
|
|
|
3833
|
+
// rc.48: validate per-topic config + isolateTopics relationship.
|
|
3834
|
+
// Per-topic SdkOptions overrides only take effect when each topic
|
|
3835
|
+
// gets its own SDK Query (isolateTopics: true). Without isolation
|
|
3836
|
+
// the Query is fixed at first-spawn time; subsequent topic-scoped
|
|
3837
|
+
// messages share that Query regardless of topic-level overrides.
|
|
3838
|
+
for (const [chatId, chatCfg] of Object.entries(config.chats)) {
|
|
3839
|
+
if (!chatCfg.topics || typeof chatCfg.topics !== 'object') continue;
|
|
3840
|
+
const overrideTopics = Object.entries(chatCfg.topics)
|
|
3841
|
+
.filter(([, t]) => t && typeof t === 'object'
|
|
3842
|
+
&& Object.keys(t).some((k) => k !== 'name'));
|
|
3843
|
+
if (overrideTopics.length === 0) continue;
|
|
3844
|
+
if (chatCfg.isolateTopics !== true) {
|
|
3845
|
+
const ids = overrideTopics.map(([id]) => id).join(', ');
|
|
3846
|
+
console.warn(`[${BOT_NAME}] WARN: chat ${chatId} (${chatCfg.name}) has topic overrides on topic_ids=${ids} but isolateTopics is not true — overrides will be IGNORED. Set isolateTopics: true to make per-topic config take effect.`);
|
|
3847
|
+
logEvent('topic-override-without-isolation', {
|
|
3848
|
+
chat_id: chatId, name: chatCfg.name, topic_ids: ids,
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3798
3853
|
bot = createBot(config.bot.token);
|
|
3799
3854
|
|
|
3800
3855
|
// Graceful shutdown: stop accepting new inbound, drain in-flight pendings
|