polygram 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -3
- package/config.example.json +23 -1
- package/lib/codex/binary.js +287 -0
- package/lib/codex/delivery-finalizer.js +97 -0
- package/lib/codex/diagnostics.js +483 -0
- package/lib/codex/event-normalizer.js +50 -0
- package/lib/codex/host-identity.js +103 -0
- package/lib/codex/model-catalog.js +359 -0
- package/lib/codex/retention-scheduler.js +59 -0
- package/lib/codex/runtime-controller.js +1292 -0
- package/lib/codex/runtime-profile.js +888 -0
- package/lib/codex/spawn-context.js +87 -0
- package/lib/db/auto-resume.js +77 -9
- package/lib/db/codex-reconciliation.js +490 -0
- package/lib/db/codex-retention.js +139 -0
- package/lib/db/sessions.js +102 -1
- package/lib/db.js +3418 -19
- package/lib/error/classify.js +140 -0
- package/lib/handlers/abort.js +143 -5
- package/lib/handlers/autosteer.js +256 -1
- package/lib/handlers/codex-reconciliation.js +230 -0
- package/lib/handlers/config-callback.js +324 -89
- package/lib/handlers/config-ui.js +401 -39
- package/lib/handlers/dispatcher.js +71 -8
- package/lib/handlers/edit-correction.js +11 -1
- package/lib/handlers/edit-redelivery.js +230 -4
- package/lib/handlers/gate-inbound.js +7 -0
- package/lib/handlers/replay-disposition.js +133 -11
- package/lib/handlers/slash-commands.js +443 -37
- package/lib/ipc/client.js +22 -12
- package/lib/ipc/runtime-directory.js +208 -0
- package/lib/ipc/server.js +18 -12
- package/lib/model-costs.js +4 -3
- package/lib/pm-interface.js +33 -0
- package/lib/prompt.js +15 -9
- package/lib/rewind/rewind.js +21 -1
- package/lib/runtime-config.js +605 -0
- package/lib/sdk/build-options.js +4 -1
- package/lib/sdk/callbacks.js +46 -3
- package/lib/util/claude-session-jsonl.js +3 -2
- package/migrations/015-agent-runtime-sessions.sql +50 -0
- package/migrations/016-agent-turn-attempts.sql +211 -0
- package/migrations/017-config-runtime-changes.sql +46 -0
- package/ops/README.md +57 -0
- package/package.json +2 -2
- package/polygram.js +697 -48
- package/scripts/doctor.js +37 -8
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const CALLBACK_PREFIX = 'cxr';
|
|
6
|
+
const CALLBACK_TOKEN_BYTES = 16;
|
|
7
|
+
|
|
8
|
+
const ACTIONS = Object.freeze({
|
|
9
|
+
incorporated: Object.freeze({
|
|
10
|
+
callback: 'i',
|
|
11
|
+
disposition: 'incorporated',
|
|
12
|
+
reason: 'owner marked the ambiguous input incorporated via Telegram',
|
|
13
|
+
acknowledgement: 'Marked incorporated. No retry was authorized.',
|
|
14
|
+
}),
|
|
15
|
+
dismissed: Object.freeze({
|
|
16
|
+
callback: 'd',
|
|
17
|
+
disposition: 'dismissed',
|
|
18
|
+
reason: 'owner dismissed the ambiguous input without retry via Telegram',
|
|
19
|
+
acknowledgement: 'Dismissed without retry.',
|
|
20
|
+
}),
|
|
21
|
+
retry: Object.freeze({
|
|
22
|
+
callback: 'r',
|
|
23
|
+
disposition: 'retry-authorized',
|
|
24
|
+
reason: 'owner authorized one retry after the duplicate-risk warning via Telegram',
|
|
25
|
+
acknowledgement: 'One retry was authorized. It remains fenced by containment.',
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ACTION_BY_CALLBACK = new Map(
|
|
30
|
+
Object.values(ACTIONS).map((action) => [action.callback, action]),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function attemptToken(attemptId) {
|
|
34
|
+
if (typeof attemptId !== 'string' || attemptId.length === 0) {
|
|
35
|
+
throw new TypeError('Codex reconciliation attempt ID is required');
|
|
36
|
+
}
|
|
37
|
+
return createHash('sha256')
|
|
38
|
+
.update(attemptId)
|
|
39
|
+
.digest('hex')
|
|
40
|
+
.slice(0, CALLBACK_TOKEN_BYTES * 2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function callbackData(action, attemptId) {
|
|
44
|
+
const value = `${CALLBACK_PREFIX}:${action.callback}:${attemptToken(attemptId)}`;
|
|
45
|
+
if (Buffer.byteLength(value, 'utf8') > 64) {
|
|
46
|
+
throw new TypeError('Codex reconciliation callback exceeds Telegram limits');
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildCodexReconciliationView(attempt) {
|
|
52
|
+
if (
|
|
53
|
+
!attempt
|
|
54
|
+
|| typeof attempt.attempt_id !== 'string'
|
|
55
|
+
|| typeof attempt.telegram_message_id !== 'string'
|
|
56
|
+
) {
|
|
57
|
+
throw new TypeError('Codex reconciliation view requires a located attempt');
|
|
58
|
+
}
|
|
59
|
+
const quarantined = attempt.containment_status === 'quarantined';
|
|
60
|
+
const containment = quarantined
|
|
61
|
+
? 'This decision does not release containment quarantine; the prescribed host reboot is still required.'
|
|
62
|
+
: 'This decision does not release containment quarantine.';
|
|
63
|
+
return {
|
|
64
|
+
text: [
|
|
65
|
+
'⚠️ Codex input needs reconciliation',
|
|
66
|
+
`Original Telegram message #${attempt.telegram_message_id} may already have been incorporated.`,
|
|
67
|
+
'Choose one owner action. Retrying can duplicate output or side effects.',
|
|
68
|
+
containment,
|
|
69
|
+
].join('\n'),
|
|
70
|
+
reply_markup: {
|
|
71
|
+
inline_keyboard: [
|
|
72
|
+
[
|
|
73
|
+
{
|
|
74
|
+
text: '✓ Mark incorporated',
|
|
75
|
+
callback_data: callbackData(ACTIONS.incorporated, attempt.attempt_id),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
text: 'Dismiss without retry',
|
|
79
|
+
callback_data: callbackData(ACTIONS.dismissed, attempt.attempt_id),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
[{
|
|
83
|
+
text: '⚠ Authorize one retry (duplicate risk)',
|
|
84
|
+
callback_data: callbackData(ACTIONS.retry, attempt.attempt_id),
|
|
85
|
+
}],
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function configuredOperatorUserId(config) {
|
|
92
|
+
const configured = config?.bot?.operatorUserId
|
|
93
|
+
?? config?.bot?.adminChatId;
|
|
94
|
+
const value = Number(configured);
|
|
95
|
+
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function acknowledge(ctx, text, showAlert = false) {
|
|
99
|
+
if (typeof ctx?.answerCallbackQuery !== 'function') return;
|
|
100
|
+
await ctx.answerCallbackQuery({
|
|
101
|
+
text,
|
|
102
|
+
...(showAlert ? { show_alert: true } : {}),
|
|
103
|
+
}).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createHandleCodexReconciliationCallback({
|
|
107
|
+
config,
|
|
108
|
+
db,
|
|
109
|
+
intentLock,
|
|
110
|
+
getSessionKey,
|
|
111
|
+
retryAttemptId = randomUUID,
|
|
112
|
+
now = Date.now,
|
|
113
|
+
isAuthorizedActor = null,
|
|
114
|
+
logger = console,
|
|
115
|
+
} = {}) {
|
|
116
|
+
if (
|
|
117
|
+
!config
|
|
118
|
+
|| typeof db?.listUnresolvedCodexAttempts !== 'function'
|
|
119
|
+
|| typeof db?.reconcileCodexAttempt !== 'function'
|
|
120
|
+
|| typeof intentLock?.acquire !== 'function'
|
|
121
|
+
|| typeof getSessionKey !== 'function'
|
|
122
|
+
|| typeof retryAttemptId !== 'function'
|
|
123
|
+
|| typeof now !== 'function'
|
|
124
|
+
) {
|
|
125
|
+
throw new TypeError('Codex reconciliation handler dependencies are incomplete');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return async function handleCodexReconciliationCallback(ctx) {
|
|
129
|
+
const data = String(ctx?.callbackQuery?.data ?? '');
|
|
130
|
+
const match = /^cxr:([idr]):([a-f0-9]{32})$/.exec(data);
|
|
131
|
+
if (!match) return false;
|
|
132
|
+
|
|
133
|
+
const action = ACTION_BY_CALLBACK.get(match[1]);
|
|
134
|
+
const token = match[2];
|
|
135
|
+
const message = ctx.callbackQuery?.message;
|
|
136
|
+
const chatId = message?.chat?.id == null
|
|
137
|
+
? null
|
|
138
|
+
: String(message.chat.id);
|
|
139
|
+
const chatConfig = chatId == null ? null : config.chats?.[chatId];
|
|
140
|
+
if (!chatConfig) {
|
|
141
|
+
await acknowledge(ctx, 'This reconciliation is not available.', true);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const actorId = ctx.from?.id ?? ctx.callbackQuery?.from?.id;
|
|
146
|
+
const operatorUserId = configuredOperatorUserId(config);
|
|
147
|
+
const allowed = typeof isAuthorizedActor === 'function'
|
|
148
|
+
? await isAuthorizedActor({ actorId, chatId, ctx })
|
|
149
|
+
: (
|
|
150
|
+
operatorUserId != null
|
|
151
|
+
&& Number(actorId) === operatorUserId
|
|
152
|
+
);
|
|
153
|
+
if (!allowed) {
|
|
154
|
+
await acknowledge(ctx, 'Not authorised to reconcile Codex input.', true);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const threadId = message.message_thread_id?.toString() ?? null;
|
|
159
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
160
|
+
const release = await intentLock.acquire(sessionKey);
|
|
161
|
+
try {
|
|
162
|
+
const matching = db.listUnresolvedCodexAttempts({
|
|
163
|
+
session_key: sessionKey,
|
|
164
|
+
}).filter((attempt) => attemptToken(attempt.attempt_id) === token);
|
|
165
|
+
if (matching.length !== 1) {
|
|
166
|
+
await acknowledge(
|
|
167
|
+
ctx,
|
|
168
|
+
matching.length === 0
|
|
169
|
+
? 'This input was already resolved or is no longer available.'
|
|
170
|
+
: 'Reconciliation identity conflict; no action was recorded.',
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const attempt = matching[0];
|
|
177
|
+
const input = {
|
|
178
|
+
attempt_id: attempt.attempt_id,
|
|
179
|
+
disposition: action.disposition,
|
|
180
|
+
actor: `telegram-user:${actorId}`,
|
|
181
|
+
reason: action.reason,
|
|
182
|
+
ts: now(),
|
|
183
|
+
};
|
|
184
|
+
if (action.disposition === 'retry-authorized') {
|
|
185
|
+
input.retry_attempt_id = retryAttemptId();
|
|
186
|
+
input.duplicate_risk_acknowledged = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
db.reconcileCodexAttempt(input);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (
|
|
193
|
+
error?.code === 'CODEX_ATTEMPT_ALREADY_RECONCILED'
|
|
194
|
+
|| error?.code === 'CODEX_ATTEMPT_NOT_AMBIGUOUS'
|
|
195
|
+
) {
|
|
196
|
+
await acknowledge(ctx, 'This input was already resolved.', true);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (error?.code === 'CODEX_RETRY_GENERATION_NOT_TERMINAL') {
|
|
200
|
+
await acknowledge(
|
|
201
|
+
ctx,
|
|
202
|
+
'Wait for the prior Codex generation to finish before authorizing retry.',
|
|
203
|
+
true,
|
|
204
|
+
);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof ctx.editMessageReplyMarkup === 'function') {
|
|
211
|
+
await ctx.editMessageReplyMarkup({ inline_keyboard: [] }).catch((error) => {
|
|
212
|
+
logger.error?.(
|
|
213
|
+
`Codex reconciliation UI update failed: ${error?.message || error}`,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
await acknowledge(ctx, action.acknowledgement);
|
|
218
|
+
return true;
|
|
219
|
+
} finally {
|
|
220
|
+
release();
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
ACTIONS,
|
|
227
|
+
attemptToken,
|
|
228
|
+
buildCodexReconciliationView,
|
|
229
|
+
createHandleCodexReconciliationCallback,
|
|
230
|
+
};
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* When a user taps a button on the config card, this routes:
|
|
5
5
|
* 1. validate the new value;
|
|
6
6
|
* 2. mutate chatConfig in-place + persist via db.logConfigChange;
|
|
7
|
-
* 3. apply the change live to
|
|
8
|
-
*
|
|
7
|
+
* 3. apply the change live to Claude via its legacy methods, or select one
|
|
8
|
+
* complete Codex pair for the next turn via pm.selectModelSettings;
|
|
9
9
|
* 4. re-render the card with the new ✓ marker;
|
|
10
10
|
* 5. acknowledge the button press with a context-aware toast.
|
|
11
11
|
*
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
const { toTelegramHtml } = require('../telegram/format');
|
|
20
20
|
const { resolveRichTextEnabled } = require('../telegram/rich');
|
|
21
21
|
const { getTopicConfig, getConfigWriteScope } = require('../session-key');
|
|
22
|
+
const {
|
|
23
|
+
configuredRuntimeValue,
|
|
24
|
+
formatCodexSettingsOutcome,
|
|
25
|
+
getCodexCatalogEfforts,
|
|
26
|
+
getCodexCatalogModels,
|
|
27
|
+
isCodexRuntimeView,
|
|
28
|
+
resolveCodexEffortForModel,
|
|
29
|
+
withCodexSettingsOutcome,
|
|
30
|
+
} = require('./config-ui');
|
|
22
31
|
|
|
23
32
|
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
24
33
|
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
@@ -31,6 +40,8 @@ function createHandleConfigCallback({
|
|
|
31
40
|
getSessionKey,
|
|
32
41
|
formatConfigInfoText,
|
|
33
42
|
buildConfigKeyboard,
|
|
43
|
+
resolveRuntimeView = null,
|
|
44
|
+
intentLock,
|
|
34
45
|
saveConfig = () => {},
|
|
35
46
|
botName,
|
|
36
47
|
logger = console,
|
|
@@ -58,101 +69,325 @@ function createHandleConfigCallback({
|
|
|
58
69
|
return;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
73
|
+
const callbackSessionKey = getSessionKey(
|
|
74
|
+
chatId,
|
|
75
|
+
callbackThreadIdEarly,
|
|
76
|
+
chatConfig,
|
|
77
|
+
);
|
|
78
|
+
const releaseIntent = await intentLock.acquire(callbackSessionKey);
|
|
79
|
+
let intentHeld = true;
|
|
80
|
+
const releaseConfigIntent = () => {
|
|
81
|
+
if (!intentHeld) return;
|
|
82
|
+
intentHeld = false;
|
|
83
|
+
releaseIntent();
|
|
84
|
+
};
|
|
85
|
+
let runtimeView = null;
|
|
86
|
+
try {
|
|
87
|
+
if (typeof resolveRuntimeView === 'function') {
|
|
88
|
+
try {
|
|
89
|
+
runtimeView = await resolveRuntimeView({
|
|
90
|
+
sessionKey: callbackSessionKey,
|
|
91
|
+
chatId,
|
|
92
|
+
threadId: callbackThreadIdEarly,
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.error?.(
|
|
96
|
+
`[${botName}] config-callback runtime view failed: `
|
|
97
|
+
+ `${error?.code || error?.name || 'unknown'}`,
|
|
98
|
+
);
|
|
99
|
+
releaseConfigIntent();
|
|
100
|
+
await ctx.answerCallbackQuery({
|
|
101
|
+
text: 'Configuration is temporarily unavailable',
|
|
102
|
+
show_alert: true,
|
|
103
|
+
}).catch(() => {});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
65
106
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
const codex = isCodexRuntimeView(runtimeView);
|
|
108
|
+
let currentModel = null;
|
|
109
|
+
|
|
110
|
+
if (isRichText) {
|
|
111
|
+
if (value !== 'on' && value !== 'off') {
|
|
112
|
+
releaseConfigIntent();
|
|
113
|
+
await ctx.answerCallbackQuery({ text: 'Invalid richtext value' })
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
const topicConfig = getTopicConfig(chatConfig, callbackThreadIdEarly);
|
|
119
|
+
currentModel = configuredRuntimeValue(
|
|
120
|
+
chatConfig,
|
|
121
|
+
topicConfig,
|
|
122
|
+
runtimeView,
|
|
123
|
+
'model',
|
|
124
|
+
);
|
|
125
|
+
const validValues = codex
|
|
126
|
+
? setting === 'model'
|
|
127
|
+
? getCodexCatalogModels(runtimeView).map((entry) => entry.model)
|
|
128
|
+
: getCodexCatalogEfforts(runtimeView, currentModel)
|
|
129
|
+
: setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
|
|
130
|
+
if (!validValues.includes(value)) {
|
|
131
|
+
releaseConfigIntent();
|
|
132
|
+
await ctx.answerCallbackQuery({ text: `Invalid ${setting}` })
|
|
133
|
+
.catch(() => {});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Write to the scope the card belongs to: a topic card targets THAT topic
|
|
139
|
+
// (so Music ≠ General), a chat-level card the chat root. Resolving the
|
|
140
|
+
// thread BEFORE the already-set check so "Already X" compares against the
|
|
141
|
+
// topic's effective value, not the chat root (2026-06-12 bug).
|
|
142
|
+
const { scope: writeScope, threadId: writeThreadId } =
|
|
143
|
+
getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
|
|
144
|
+
allowNonIsolatedTopic: setting === 'richtext',
|
|
145
|
+
});
|
|
146
|
+
// The persisted camel-case key differs from the lowercase callback
|
|
147
|
+
// label used alongside model and effort.
|
|
148
|
+
const configField = isRichText
|
|
149
|
+
? 'richText'
|
|
150
|
+
: codex
|
|
151
|
+
? setting === 'model' ? 'codexModel' : 'codexEffort'
|
|
152
|
+
: setting;
|
|
153
|
+
const oldValue = isRichText
|
|
154
|
+
? resolveRichTextEnabled(config, chatId, callbackThreadIdEarly)
|
|
155
|
+
: (
|
|
156
|
+
writeScope[configField] != null
|
|
157
|
+
? writeScope[configField]
|
|
158
|
+
: chatConfig[configField] != null
|
|
159
|
+
? chatConfig[configField]
|
|
160
|
+
: codex ? runtimeView[setting] : chatConfig[configField]
|
|
161
|
+
);
|
|
162
|
+
const snapshots = new Map([
|
|
163
|
+
[configField, {
|
|
164
|
+
present: Object.hasOwn(writeScope, configField),
|
|
165
|
+
value: writeScope[configField],
|
|
166
|
+
}],
|
|
167
|
+
]);
|
|
168
|
+
if (codex && setting === 'model') {
|
|
169
|
+
snapshots.set('codexEffort', {
|
|
170
|
+
present: Object.hasOwn(writeScope, 'codexEffort'),
|
|
171
|
+
value: writeScope.codexEffort,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
let effortAdjustment = null;
|
|
175
|
+
let selectedEffort = setting === 'effort' ? value : null;
|
|
176
|
+
if (codex && setting === 'model') {
|
|
177
|
+
const oldEffort = writeScope.codexEffort != null
|
|
178
|
+
? writeScope.codexEffort
|
|
179
|
+
: chatConfig.codexEffort != null
|
|
180
|
+
? chatConfig.codexEffort
|
|
181
|
+
: runtimeView.effort;
|
|
182
|
+
const nextEffort = resolveCodexEffortForModel(
|
|
183
|
+
runtimeView,
|
|
184
|
+
value,
|
|
185
|
+
oldEffort,
|
|
186
|
+
);
|
|
187
|
+
if (nextEffort == null) {
|
|
188
|
+
releaseConfigIntent();
|
|
189
|
+
await ctx.answerCallbackQuery({
|
|
190
|
+
text: 'Selected model has no authenticated default effort',
|
|
191
|
+
show_alert: true,
|
|
192
|
+
}).catch(() => {});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
selectedEffort = nextEffort;
|
|
196
|
+
if (nextEffort !== oldEffort) {
|
|
197
|
+
effortAdjustment = { oldValue: oldEffort, newValue: nextEffort };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (oldValue === parsedValue) {
|
|
201
|
+
releaseConfigIntent();
|
|
202
|
+
await ctx.answerCallbackQuery({ text: `Already ${value}` })
|
|
203
|
+
.catch(() => {});
|
|
70
204
|
return;
|
|
71
205
|
}
|
|
72
|
-
}
|
|
73
206
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
207
|
+
writeScope[configField] = parsedValue;
|
|
208
|
+
if (effortAdjustment) {
|
|
209
|
+
writeScope.codexEffort = effortAdjustment.newValue;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await saveConfig();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
for (const [field, snapshot] of snapshots) {
|
|
215
|
+
if (snapshot.present) writeScope[field] = snapshot.value;
|
|
216
|
+
else delete writeScope[field];
|
|
217
|
+
}
|
|
218
|
+
logger.error?.(
|
|
219
|
+
`[${botName}] config-callback saveConfig failed: ${error.message}`,
|
|
220
|
+
);
|
|
221
|
+
releaseConfigIntent();
|
|
222
|
+
await ctx.answerCallbackQuery({
|
|
223
|
+
text: `Couldn't save ${setting}; nothing changed`,
|
|
224
|
+
show_alert: true,
|
|
225
|
+
}).catch(() => {});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const cmdUserId = ctx.callbackQuery.from?.id || null;
|
|
229
|
+
const cmdUser = ctx.callbackQuery.from?.first_name
|
|
230
|
+
|| ctx.callbackQuery.from?.username
|
|
231
|
+
|| null;
|
|
232
|
+
const auditRows = [{
|
|
233
|
+
chat_id: chatId, thread_id: writeThreadId,
|
|
234
|
+
// The audit schema intentionally keeps provider-neutral field names.
|
|
235
|
+
field: isRichText ? configField : setting,
|
|
236
|
+
old_value: oldValue, new_value: parsedValue,
|
|
237
|
+
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
238
|
+
}];
|
|
239
|
+
if (effortAdjustment) {
|
|
240
|
+
auditRows.push({
|
|
241
|
+
chat_id: chatId,
|
|
242
|
+
thread_id: writeThreadId,
|
|
243
|
+
field: 'effort',
|
|
244
|
+
old_value: effortAdjustment.oldValue,
|
|
245
|
+
new_value: effortAdjustment.newValue,
|
|
246
|
+
user: cmdUser,
|
|
247
|
+
user_id: cmdUserId,
|
|
248
|
+
source: 'inline-button',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (codex && !isRichText) {
|
|
252
|
+
try {
|
|
253
|
+
db.logConfigChanges(auditRows);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
for (const [field, snapshot] of snapshots) {
|
|
256
|
+
if (snapshot.present) writeScope[field] = snapshot.value;
|
|
257
|
+
else delete writeScope[field];
|
|
258
|
+
}
|
|
259
|
+
let rollbackFailed = false;
|
|
260
|
+
try {
|
|
261
|
+
await saveConfig();
|
|
262
|
+
} catch (rollbackError) {
|
|
263
|
+
rollbackFailed = true;
|
|
264
|
+
logger.error?.(
|
|
265
|
+
`[${botName}] config-callback rollback failed: `
|
|
266
|
+
+ rollbackError.message,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
logger.error?.(
|
|
270
|
+
`[${botName}] config-callback audit failed: ${error.message}`,
|
|
271
|
+
);
|
|
272
|
+
releaseConfigIntent();
|
|
273
|
+
await ctx.answerCallbackQuery({
|
|
274
|
+
text: rollbackFailed
|
|
275
|
+
? `Couldn't audit ${setting}; live process unchanged, `
|
|
276
|
+
+ 'persisted config needs attention'
|
|
277
|
+
: `Couldn't audit ${setting}; nothing changed`,
|
|
278
|
+
show_alert: true,
|
|
279
|
+
}).catch(() => {});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
dbWrite(() => {
|
|
284
|
+
for (const row of auditRows) db.logConfigChange(row);
|
|
285
|
+
}, `log ${configField} change`);
|
|
286
|
+
}
|
|
93
287
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
288
|
+
// Graceful application to the topic's session. Claude keeps its
|
|
289
|
+
// existing live SDK calls. Codex receives one complete pair; expected
|
|
290
|
+
// lifecycle states are reported by a discriminated outcome.
|
|
291
|
+
const callbackThreadId = callbackThreadIdEarly;
|
|
292
|
+
let applied = false;
|
|
293
|
+
let settingsResult = null;
|
|
294
|
+
if (codex && !isRichText) {
|
|
295
|
+
const selectedModel = setting === 'model' ? value : currentModel;
|
|
296
|
+
try {
|
|
297
|
+
settingsResult = await pm.selectModelSettings(callbackSessionKey, {
|
|
298
|
+
model: selectedModel,
|
|
299
|
+
effort: selectedEffort,
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
logger.error?.(
|
|
303
|
+
`[${botName}] config-callback Codex selection failed: `
|
|
304
|
+
+ `${error?.code || error?.name || 'unknown'}`,
|
|
305
|
+
);
|
|
306
|
+
settingsResult = {
|
|
307
|
+
outcome: 'live-status-unknown',
|
|
308
|
+
nextTurn: {
|
|
309
|
+
model: selectedModel,
|
|
310
|
+
effort: selectedEffort,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
runtimeView = withCodexSettingsOutcome(runtimeView, settingsResult);
|
|
315
|
+
} else if (!codex && setting === 'effort') {
|
|
316
|
+
applied = await pm.applyFlagSettings(
|
|
317
|
+
callbackSessionKey,
|
|
318
|
+
{ effortLevel: value },
|
|
319
|
+
);
|
|
320
|
+
} else if (!codex && setting === 'model') {
|
|
321
|
+
applied = await pm.setModel(callbackSessionKey, value);
|
|
322
|
+
}
|
|
323
|
+
const anyActive = !applied;
|
|
324
|
+
releaseConfigIntent();
|
|
107
325
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
326
|
+
// Re-render the card with the new ✓ marker. Detect original card
|
|
327
|
+
// type (model-only / effort-only / both) by counting rows in the
|
|
328
|
+
// existing reply_markup so the user sees the same layout they
|
|
329
|
+
// tapped into.
|
|
330
|
+
const existingRows =
|
|
331
|
+
ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
|
|
332
|
+
const showRow = existingRows >= 2 ? 'all' : setting;
|
|
333
|
+
const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
|
|
334
|
+
const effectiveRichText = resolveRichTextEnabled(
|
|
335
|
+
config,
|
|
336
|
+
chatId,
|
|
337
|
+
callbackThreadId,
|
|
338
|
+
);
|
|
339
|
+
const newInfo = await formatConfigInfoText(
|
|
340
|
+
chatConfig,
|
|
341
|
+
showRow,
|
|
342
|
+
codex ? callbackSessionKey : chatId,
|
|
343
|
+
_cbTopicCfg,
|
|
344
|
+
effectiveRichText,
|
|
345
|
+
runtimeView,
|
|
346
|
+
);
|
|
347
|
+
const newKeyboard = buildConfigKeyboard(
|
|
348
|
+
chatConfig,
|
|
349
|
+
showRow,
|
|
350
|
+
_cbTopicCfg,
|
|
351
|
+
effectiveRichText,
|
|
352
|
+
runtimeView,
|
|
353
|
+
);
|
|
354
|
+
try {
|
|
355
|
+
const { text: html, parseMode } = toTelegramHtml(newInfo);
|
|
356
|
+
await ctx.editMessageText(html, {
|
|
357
|
+
reply_markup: newKeyboard,
|
|
358
|
+
...(parseMode && { parse_mode: parseMode }),
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
|
|
362
|
+
}
|
|
125
363
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
364
|
+
// Rich delivery changes immediately, but its authoring hint changes
|
|
365
|
+
// on the next session spawn; surface both timings in the acknowledgement.
|
|
366
|
+
const ackText = isRichText
|
|
367
|
+
? (
|
|
368
|
+
parsedValue
|
|
369
|
+
? 'Rich text → on (delivery live; agent authors for it next session)'
|
|
370
|
+
: 'Rich text → off'
|
|
371
|
+
)
|
|
372
|
+
: codex
|
|
373
|
+
? (
|
|
374
|
+
`${setting} → ${value}`
|
|
375
|
+
+ (
|
|
376
|
+
effortAdjustment
|
|
377
|
+
? `; effort → ${effortAdjustment.newValue}`
|
|
378
|
+
: ''
|
|
379
|
+
)
|
|
380
|
+
+ formatCodexSettingsOutcome(settingsResult)
|
|
381
|
+
)
|
|
382
|
+
: (
|
|
383
|
+
anyActive
|
|
384
|
+
? `${setting} → ${value} — switching when finished`
|
|
385
|
+
: `${setting} → ${value}`
|
|
386
|
+
);
|
|
387
|
+
await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
|
|
388
|
+
} finally {
|
|
389
|
+
releaseConfigIntent();
|
|
148
390
|
}
|
|
149
|
-
|
|
150
|
-
// Rich delivery changes immediately, but its authoring hint changes
|
|
151
|
-
// on the next session spawn; surface both timings in the acknowledgement.
|
|
152
|
-
const ackText = isRichText
|
|
153
|
-
? (parsedValue ? 'Rich text → on (delivery live; agent authors for it next session)' : 'Rich text → off')
|
|
154
|
-
: (anyActive ? `${setting} → ${value} — switching when finished` : `${setting} → ${value}`);
|
|
155
|
-
await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
|
|
156
391
|
};
|
|
157
392
|
}
|
|
158
393
|
|