polygram 0.8.0-rc.2 → 0.8.0-rc.21
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/lib/agent-loader.js +219 -64
- package/lib/approval-ui.js +135 -0
- package/lib/autosteer-buffer.js +131 -0
- package/lib/canonical-json.js +44 -0
- package/lib/error-classify.js +38 -9
- package/lib/history-preload.js +160 -0
- package/lib/pm-interface.js +95 -0
- package/lib/pm-router.js +159 -0
- package/lib/process-manager-sdk.js +32 -1
- package/lib/process-manager.js +13 -0
- package/lib/status-reactions.js +70 -19
- package/package.json +1 -1
- package/polygram.js +412 -204
|
@@ -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.21",
|
|
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/lib/agent-loader.js
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
* v4 plan §6.5.5).
|
|
4
4
|
*
|
|
5
5
|
* Background: today's CLI pm passes `--agent <name>` on spawn; the
|
|
6
|
-
* Claude CLI then loads that agent's
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
10
|
-
*
|
|
6
|
+
* Claude CLI then loads that agent's content. Phase 0 gate 15 was
|
|
7
|
+
* DEFER — the SDK's `Options.agents` is for in-memory subagent
|
|
8
|
+
* definitions (the Task tool), NOT a "run THIS query AS this agent"
|
|
9
|
+
* mechanism. So polygram reads the agent file itself and passes its
|
|
10
|
+
* content as `systemPrompt`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* Search order (rc.13+ — supports BOTH Claude Code's standard
|
|
13
|
+
* single-file convention AND polygram's pre-0.8.0 directory layout):
|
|
14
|
+
*
|
|
15
|
+
* 1. `<cwd>/.claude/agents/<name>.md` — Claude Code project-level
|
|
16
|
+
* 2. `<homeDir>/.claude/agents/<name>.md` — Claude Code user-level
|
|
17
|
+
* 3. `<cwd>/.claude/agents/<name>/CLAUDE.md` — polygram convention
|
|
18
|
+
* (also `AGENTS.md`, `system-prompt.txt`)
|
|
19
|
+
* 4. `<homeDir>/.claude/agents/<name>/CLAUDE.md` — polygram legacy
|
|
20
|
+
* (also `AGENTS.md`, `system-prompt.txt`)
|
|
21
|
+
*
|
|
22
|
+
* Single-file Claude Code agents may have YAML frontmatter; we strip
|
|
23
|
+
* it before using the body as systemPrompt. Frontmatter `model` /
|
|
24
|
+
* `effort` are merged into the bundle.raw so composeSdkOptions can
|
|
25
|
+
* use them as agent-level defaults.
|
|
18
26
|
*
|
|
19
27
|
* Used by `polygram.js` `buildSdkOptions(sessionKey, ctx)` —
|
|
20
28
|
* Phase 1 step 14.
|
|
@@ -31,92 +39,229 @@
|
|
|
31
39
|
const fs = require('fs');
|
|
32
40
|
const path = require('path');
|
|
33
41
|
|
|
34
|
-
const cache = new Map(); //
|
|
42
|
+
const cache = new Map(); // cacheKey → AgentBundle
|
|
43
|
+
|
|
44
|
+
// Resolve agent file by checking each search path in order.
|
|
45
|
+
// Returns { kind: 'file'|'dir', path, dir | null } or null.
|
|
46
|
+
function resolveAgentLocation(agentName, homeDir, cwd) {
|
|
47
|
+
const fileCandidates = [];
|
|
48
|
+
if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
|
|
49
|
+
fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
|
|
50
|
+
for (const p of fileCandidates) {
|
|
51
|
+
if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
|
|
52
|
+
}
|
|
53
|
+
const dirCandidates = [];
|
|
54
|
+
if (cwd) dirCandidates.push(path.join(cwd, '.claude', 'agents', agentName));
|
|
55
|
+
dirCandidates.push(path.join(homeDir, '.claude', 'agents', agentName));
|
|
56
|
+
for (const d of dirCandidates) {
|
|
57
|
+
if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
|
|
58
|
+
return { kind: 'dir', path: d, dir: d };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Strip leading YAML frontmatter (---\n...\n---\n) from markdown.
|
|
65
|
+
function stripFrontmatter(content) {
|
|
66
|
+
if (typeof content !== 'string' || !content.startsWith('---\n')) return content;
|
|
67
|
+
const end = content.indexOf('\n---\n', 4);
|
|
68
|
+
if (end === -1) return content;
|
|
69
|
+
return content.slice(end + 5);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Recursively expand Claude Code @<file> import directives. A line
|
|
73
|
+
// starting with `@<path>` is replaced with the file's contents
|
|
74
|
+
// (frontmatter stripped, imports recursively expanded). Paths
|
|
75
|
+
// resolve relative to the importing file's directory FIRST, then
|
|
76
|
+
// fall back to cwd. Cycle detection via visited Set.
|
|
77
|
+
//
|
|
78
|
+
// rc.15: pre-rc.15 the literal "@_shumabit-base.md" reached the
|
|
79
|
+
// model verbatim because polygram's loader didn't process imports.
|
|
80
|
+
// Symptom: agent appeared loaded but the system prompt was
|
|
81
|
+
// effectively empty (just an unresolved import directive).
|
|
82
|
+
function expandImports(content, importingFile, cwd, visited, logger) {
|
|
83
|
+
if (typeof content !== 'string' || !content) return content;
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const m = /^@(\S+)\s*$/.exec(line);
|
|
88
|
+
if (!m) {
|
|
89
|
+
out.push(line);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const ref = m[1];
|
|
93
|
+
const importingDir = path.dirname(importingFile);
|
|
94
|
+
// Resolution order: relative to importing file's dir; relative
|
|
95
|
+
// to cwd; absolute path as-is.
|
|
96
|
+
const candidates = [];
|
|
97
|
+
if (path.isAbsolute(ref)) {
|
|
98
|
+
candidates.push(ref);
|
|
99
|
+
} else {
|
|
100
|
+
candidates.push(path.join(importingDir, ref));
|
|
101
|
+
if (cwd) candidates.push(path.join(cwd, ref));
|
|
102
|
+
}
|
|
103
|
+
let resolved = null;
|
|
104
|
+
for (const c of candidates) {
|
|
105
|
+
if (fs.existsSync(c)) { resolved = c; break; }
|
|
106
|
+
}
|
|
107
|
+
if (!resolved) {
|
|
108
|
+
logger?.warn?.(`[agent-loader] @-import not found: ${ref} (in ${importingFile})`);
|
|
109
|
+
out.push(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (visited.has(resolved)) {
|
|
113
|
+
logger?.warn?.(`[agent-loader] @-import cycle: ${resolved}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
visited.add(resolved);
|
|
117
|
+
let imported = '';
|
|
118
|
+
try {
|
|
119
|
+
imported = fs.readFileSync(resolved, 'utf8');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger?.error?.(`[agent-loader] reading @-import ${resolved}: ${err.message}`);
|
|
122
|
+
out.push(line);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// Strip frontmatter from imported file (same convention as
|
|
126
|
+
// top-level agent file) and recursively expand its imports.
|
|
127
|
+
imported = stripFrontmatter(imported);
|
|
128
|
+
imported = expandImports(imported, resolved, cwd, visited, logger);
|
|
129
|
+
out.push(imported);
|
|
130
|
+
}
|
|
131
|
+
return out.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Parse a tiny subset of YAML frontmatter (key: value lines).
|
|
135
|
+
function parseFrontmatter(content) {
|
|
136
|
+
if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
|
|
137
|
+
const end = content.indexOf('\n---\n', 4);
|
|
138
|
+
if (end === -1) return {};
|
|
139
|
+
const block = content.slice(4, end);
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const line of block.split('\n')) {
|
|
142
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
|
|
143
|
+
if (!m) continue;
|
|
144
|
+
let v = m[2].trim();
|
|
145
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
146
|
+
v = v.slice(1, -1);
|
|
147
|
+
}
|
|
148
|
+
out[m[1]] = v;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
35
152
|
|
|
36
153
|
/**
|
|
37
154
|
* Load an agent bundle from disk.
|
|
38
155
|
*
|
|
39
|
-
* @param {string} agentName
|
|
156
|
+
* @param {string} agentName
|
|
40
157
|
* @param {object} opts
|
|
41
158
|
* @param {string} [opts.homeDir] — defaults to process.env.HOME.
|
|
42
|
-
*
|
|
159
|
+
* @param {string} [opts.cwd] — chat's working directory; checked
|
|
160
|
+
* FIRST for Claude Code project-level agent discovery.
|
|
43
161
|
* @param {object} [opts.logger] — error logger.
|
|
44
|
-
*
|
|
45
|
-
* @returns {AgentBundle}
|
|
46
|
-
* { agentName, agentDir, systemPrompt, skills: string[],
|
|
47
|
-
* mcpServers: object, raw: settingsJson }
|
|
48
|
-
*
|
|
49
|
-
* Throws `{ code: 'AGENT_NOT_FOUND' }` if the agent dir doesn't
|
|
50
|
-
* exist. Does NOT throw on partial agents (missing CLAUDE.md or
|
|
51
|
-
* skills/ etc — fields just default to null/empty).
|
|
52
162
|
*/
|
|
53
|
-
function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
|
|
54
|
-
|
|
163
|
+
function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger = console } = {}) {
|
|
164
|
+
// Cache key includes cwd because the same agentName can resolve
|
|
165
|
+
// to different files when called from different chats with
|
|
166
|
+
// different cwds (e.g. shumabit-claude vs shumabit-partners).
|
|
167
|
+
const cacheKey = agentName + '\x00' + (cwd || '');
|
|
168
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
55
169
|
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
170
|
+
const loc = resolveAgentLocation(agentName, homeDir, cwd);
|
|
171
|
+
if (!loc) {
|
|
172
|
+
const looked = [
|
|
173
|
+
cwd ? cwd + '/.claude/agents/' + agentName + '.md' : null,
|
|
174
|
+
homeDir + '/.claude/agents/' + agentName + '.md',
|
|
175
|
+
cwd ? cwd + '/.claude/agents/' + agentName + '/' : null,
|
|
176
|
+
homeDir + '/.claude/agents/' + agentName + '/',
|
|
177
|
+
].filter(Boolean).join(', ');
|
|
58
178
|
throw Object.assign(
|
|
59
|
-
new Error(
|
|
60
|
-
{ code: 'AGENT_NOT_FOUND',
|
|
179
|
+
new Error('agent not found: ' + agentName + ' (looked in ' + looked + ')'),
|
|
180
|
+
{ code: 'AGENT_NOT_FOUND', searchPaths: looked },
|
|
61
181
|
);
|
|
62
182
|
}
|
|
63
183
|
|
|
64
|
-
// System prompt: prefer CLAUDE.md (the standard polygram convention),
|
|
65
|
-
// fall back to AGENTS.md (OpenClaw legacy), then to a single-line
|
|
66
|
-
// file `system-prompt.txt` if either of the markdown files is
|
|
67
|
-
// absent. Whichever is present, read as UTF-8 string.
|
|
68
184
|
let systemPrompt = null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
185
|
+
let frontmatter = {};
|
|
186
|
+
let agentPath = loc.path;
|
|
187
|
+
|
|
188
|
+
if (loc.kind === 'file') {
|
|
189
|
+
// Claude Code single-file format. Read whole file, parse and
|
|
190
|
+
// strip frontmatter, body becomes systemPrompt. Then expand
|
|
191
|
+
// any @<file> import directives recursively (rc.15).
|
|
192
|
+
try {
|
|
193
|
+
const raw = fs.readFileSync(loc.path, 'utf8');
|
|
194
|
+
frontmatter = parseFrontmatter(raw);
|
|
195
|
+
const stripped = stripFrontmatter(raw);
|
|
196
|
+
const visited = new Set([loc.path]);
|
|
197
|
+
systemPrompt = expandImports(stripped, loc.path, cwd, visited, logger);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// polygram directory layout. CLAUDE.md > AGENTS.md > system-prompt.txt.
|
|
203
|
+
for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
|
|
204
|
+
const p = path.join(loc.dir, fname);
|
|
205
|
+
if (fs.existsSync(p)) {
|
|
206
|
+
try {
|
|
207
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
208
|
+
// Expand @-imports for directory-layout agents too —
|
|
209
|
+
// their content might also reference shared base files.
|
|
210
|
+
const visited = new Set([p]);
|
|
211
|
+
systemPrompt = expandImports(raw, p, cwd, visited, logger);
|
|
212
|
+
agentPath = p;
|
|
213
|
+
break;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
logger.error?.('[agent-loader] reading ' + p + ': ' + err.message);
|
|
216
|
+
}
|
|
77
217
|
}
|
|
78
218
|
}
|
|
79
219
|
}
|
|
80
220
|
|
|
81
|
-
// Settings
|
|
82
|
-
// (mcpServers, model, effort defaults, etc.).
|
|
221
|
+
// Settings.json — only meaningful for directory-layout agents.
|
|
83
222
|
let settings = {};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
223
|
+
if (loc.dir) {
|
|
224
|
+
const settingsPath = path.join(loc.dir, 'settings.json');
|
|
225
|
+
if (fs.existsSync(settingsPath)) {
|
|
226
|
+
try {
|
|
227
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logger.error?.('[agent-loader] parsing ' + settingsPath + ': ' + err.message);
|
|
230
|
+
}
|
|
90
231
|
}
|
|
91
232
|
}
|
|
92
233
|
|
|
93
|
-
// Skills
|
|
94
|
-
// `Options.skills` accepts a string[] of skill names.
|
|
95
|
-
const skillsDir = path.join(agentDir, 'skills');
|
|
234
|
+
// Skills (only for directory layout).
|
|
96
235
|
let skills = [];
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
236
|
+
if (loc.dir) {
|
|
237
|
+
const skillsDir = path.join(loc.dir, 'skills');
|
|
238
|
+
if (fs.existsSync(skillsDir)) {
|
|
239
|
+
try {
|
|
240
|
+
skills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
241
|
+
.filter((d) => d.isDirectory())
|
|
242
|
+
.map((d) => d.name);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
logger.error?.('[agent-loader] enumerating ' + skillsDir + ': ' + err.message);
|
|
245
|
+
}
|
|
104
246
|
}
|
|
105
247
|
}
|
|
106
248
|
|
|
107
249
|
const mcpServers = settings.mcpServers ?? {};
|
|
108
250
|
|
|
251
|
+
// Frontmatter merged with settings — composeSdkOptions can pick up
|
|
252
|
+
// model/effort overrides from either source.
|
|
253
|
+
const raw = { ...frontmatter, ...settings };
|
|
254
|
+
|
|
109
255
|
const bundle = {
|
|
110
256
|
agentName,
|
|
111
|
-
|
|
257
|
+
agentPath,
|
|
258
|
+
agentDir: loc.dir,
|
|
112
259
|
systemPrompt,
|
|
113
260
|
skills,
|
|
114
261
|
mcpServers,
|
|
115
|
-
|
|
116
|
-
// (e.g. agent-level model/effort defaults).
|
|
117
|
-
raw: settings,
|
|
262
|
+
raw,
|
|
118
263
|
};
|
|
119
|
-
cache.set(
|
|
264
|
+
cache.set(cacheKey, bundle);
|
|
120
265
|
return bundle;
|
|
121
266
|
}
|
|
122
267
|
|
|
@@ -166,4 +311,14 @@ function clearCache() {
|
|
|
166
311
|
cache.clear();
|
|
167
312
|
}
|
|
168
313
|
|
|
169
|
-
module.exports = {
|
|
314
|
+
module.exports = {
|
|
315
|
+
loadAgent,
|
|
316
|
+
composeSdkOptions,
|
|
317
|
+
clearCache,
|
|
318
|
+
// Internals for tests.
|
|
319
|
+
_resolveAgentLocation: resolveAgentLocation,
|
|
320
|
+
_stripFrontmatter: stripFrontmatter,
|
|
321
|
+
_parseFrontmatter: parseFrontmatter,
|
|
322
|
+
_expandImports: expandImports,
|
|
323
|
+
_cache: cache,
|
|
324
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure UI builders for the approval flow's Telegram surface.
|
|
3
|
+
*
|
|
4
|
+
* - 2-button keyboard (CLI pm IPC approval-hook flow)
|
|
5
|
+
* - 4-button keyboard (rc.6 SDK pm canUseTool flow with persisted
|
|
6
|
+
* "Always allow / Always deny" via chat_tool_decisions)
|
|
7
|
+
* - Card text with friendly heading + clipped tool_input body
|
|
8
|
+
*
|
|
9
|
+
* No runtime dependencies — these are pure transforms suitable for
|
|
10
|
+
* unit-testing in isolation. The polygram.js side wires them to
|
|
11
|
+
* `tg(bot, 'sendMessage', ...)` / `editMessageText`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 2-button keyboard for the legacy IPC approval flow.
|
|
18
|
+
* @param {number|string} approvalId
|
|
19
|
+
* @param {string} token
|
|
20
|
+
*/
|
|
21
|
+
function buildApprovalKeyboard(approvalId, token) {
|
|
22
|
+
return {
|
|
23
|
+
inline_keyboard: [[
|
|
24
|
+
{ text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
|
|
25
|
+
{ text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
|
|
26
|
+
]],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 4-button keyboard for the SDK canUseTool flow (rc.6 Phase 2 step 6).
|
|
32
|
+
* "Always allow" / "Always deny" rows persist the decision into
|
|
33
|
+
* `chat_tool_decisions` so subsequent invocations of the same tool
|
|
34
|
+
* with the same input short-circuit.
|
|
35
|
+
*
|
|
36
|
+
* Callback_data conventions:
|
|
37
|
+
* approve:<id>:<token> — one-time allow
|
|
38
|
+
* deny:<id>:<token> — one-time deny
|
|
39
|
+
* approve-always:<id>:<token> — allow + persist
|
|
40
|
+
* deny-always:<id>:<token> — deny + persist
|
|
41
|
+
*
|
|
42
|
+
* @param {number|string} approvalId
|
|
43
|
+
* @param {string} token
|
|
44
|
+
*/
|
|
45
|
+
function buildApprovalKeyboardWithAlways(approvalId, token) {
|
|
46
|
+
return {
|
|
47
|
+
inline_keyboard: [
|
|
48
|
+
[
|
|
49
|
+
{ text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
|
|
50
|
+
{ text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
{ text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
|
|
54
|
+
{ text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
|
|
55
|
+
],
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format a tool_input value for the inline-keyboard card body.
|
|
62
|
+
* Clips aggressively so the whole card stays under Telegram's
|
|
63
|
+
* 4096-char limit (approval card has surrounding metadata too).
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} input — string OR any JSON-able object
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function formatToolInputForCard(input) {
|
|
69
|
+
let s;
|
|
70
|
+
try {
|
|
71
|
+
s = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
|
|
72
|
+
} catch {
|
|
73
|
+
s = String(input);
|
|
74
|
+
}
|
|
75
|
+
// JSON.stringify(undefined) returns undefined, and objects with
|
|
76
|
+
// a circular toJSON could surface odd values too. Fall back to
|
|
77
|
+
// String() so we always operate on a real string.
|
|
78
|
+
if (typeof s !== 'string') s = String(input);
|
|
79
|
+
if (s.length <= 1200) return s;
|
|
80
|
+
return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Approval card text. Plain-text only (NO parse_mode) — tool_input
|
|
85
|
+
* originates from Claude and could contain Markdown specials or
|
|
86
|
+
* tg:// links crafted for phishing.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} row — approval row from the approvals store
|
|
89
|
+
* @param {string} row.tool_name
|
|
90
|
+
* @param {number|string|null} row.turn_id
|
|
91
|
+
* @param {string} row.requester_chat_id
|
|
92
|
+
* @param {object|string|null} [row.tool_input_json]
|
|
93
|
+
* @param {object|string|null} [row.tool_input] — alias for tool_input_json
|
|
94
|
+
* @param {number} row.timeout_ts — unix ms when the row expires
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {string} [opts.resolvedBy] — heading override for resolved cards
|
|
97
|
+
* (e.g. "✓ Approved by ivan").
|
|
98
|
+
* When set, footer is dropped.
|
|
99
|
+
* When unset, heading is "Approval needed — <tool>"
|
|
100
|
+
* and footer shows seconds-to-expire.
|
|
101
|
+
* @param {() => number} [opts.now] — clock injection for tests
|
|
102
|
+
*
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
function approvalCardText(row, opts = {}) {
|
|
106
|
+
const now = (typeof opts.now === 'function' ? opts.now : Date.now)();
|
|
107
|
+
const heading = opts.resolvedBy
|
|
108
|
+
? opts.resolvedBy
|
|
109
|
+
: `Approval needed — ${row.tool_name}`;
|
|
110
|
+
// tool_input may arrive as a parsed object OR a JSON string under
|
|
111
|
+
// either key name depending on the call site.
|
|
112
|
+
const inputSource = row.tool_input_json !== undefined
|
|
113
|
+
? row.tool_input_json
|
|
114
|
+
: row.tool_input;
|
|
115
|
+
const parsed = typeof inputSource === 'string'
|
|
116
|
+
? safeParse(inputSource)
|
|
117
|
+
: inputSource;
|
|
118
|
+
const body = formatToolInputForCard(parsed);
|
|
119
|
+
const ttl = Math.max(0, Math.round((row.timeout_ts - now) / 1000));
|
|
120
|
+
const footer = opts.resolvedBy ? '' : `\n\n⏱ expires in ${ttl}s`;
|
|
121
|
+
return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function safeParse(s) {
|
|
125
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
buildApprovalKeyboard,
|
|
130
|
+
buildApprovalKeyboardWithAlways,
|
|
131
|
+
formatToolInputForCard,
|
|
132
|
+
approvalCardText,
|
|
133
|
+
// Internals exposed for tests
|
|
134
|
+
_safeParse: safeParse,
|
|
135
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session buffer for mid-turn user follow-ups (autosteer + /steer).
|
|
3
|
+
*
|
|
4
|
+
* 0.8.0-rc.9: lands the steer mechanism that survived production. Earlier
|
|
5
|
+
* rcs pushed `priority:'now'` SDKUserMessages onto the SDK input
|
|
6
|
+
* iterable mid-tool-use; the CLI binary's `m87` gate rejected them with
|
|
7
|
+
* `result.subtype = error_during_execution` because the transcript shape
|
|
8
|
+
* (assistant ending with tool_use → next user message NOT being a
|
|
9
|
+
* tool_result) is malformed per Anthropic's API contract.
|
|
10
|
+
*
|
|
11
|
+
* The mechanism we landed on: append the follow-up to a per-session
|
|
12
|
+
* buffer; on every PostToolBatch hook fire, drain the buffer into the
|
|
13
|
+
* hook's `additionalContext` field wrapped in a `<channel
|
|
14
|
+
* source="user-followup">…</channel>` tag — the same framing Channels
|
|
15
|
+
* MCP uses, which Claude is trained to trust as legitimate
|
|
16
|
+
* out-of-band user context (vs. prompt-injection inside tool output,
|
|
17
|
+
* which the model defends against by refusing to follow).
|
|
18
|
+
*
|
|
19
|
+
* Spike result (post-tool-batch-spike-v2.mjs): with this framing, the
|
|
20
|
+
* marker "spike-marker-9d3e" injected via additionalContext was
|
|
21
|
+
* incorporated verbatim into the assistant's final answer. With the
|
|
22
|
+
* earlier `<user_message_during_turn>` framing, the model recognised
|
|
23
|
+
* it as prompt-injection-shaped and refused.
|
|
24
|
+
*
|
|
25
|
+
* Why a buffer module instead of inlining: per-sessionKey state lives
|
|
26
|
+
* outside the pm and outside polygram.js's handleMessage so both
|
|
27
|
+
* autosteer (handleMessage line ~2418) and /steer (line ~1975) can
|
|
28
|
+
* share it. pm-sdk binds a hook callback per spawn that closes over
|
|
29
|
+
* its sessionKey and drains this buffer.
|
|
30
|
+
*
|
|
31
|
+
* Edge: tool-less turns (Claude answers without firing a tool). The
|
|
32
|
+
* hook never fires, so a queued message would be lost. pm-sdk's
|
|
33
|
+
* onResult handler MUST drain the buffer at turn-end and push the
|
|
34
|
+
* remainder via `inputController.push(..., { shouldQuery: false })`
|
|
35
|
+
* for next-turn injection — no m87 risk because the previous turn
|
|
36
|
+
* ended cleanly with text/end_turn before the push lands.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
function createAutosteerBuffer() {
|
|
42
|
+
// sessionKey → array of strings (in order of arrival)
|
|
43
|
+
const queues = new Map();
|
|
44
|
+
|
|
45
|
+
function append(sessionKey, text) {
|
|
46
|
+
if (!sessionKey || typeof text !== 'string' || text.length === 0) return false;
|
|
47
|
+
let q = queues.get(sessionKey);
|
|
48
|
+
if (!q) { q = []; queues.set(sessionKey, q); }
|
|
49
|
+
q.push(text);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function drain(sessionKey) {
|
|
54
|
+
const q = queues.get(sessionKey);
|
|
55
|
+
if (!q || q.length === 0) return [];
|
|
56
|
+
queues.delete(sessionKey);
|
|
57
|
+
return q;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function size(sessionKey) {
|
|
61
|
+
return queues.get(sessionKey)?.length ?? 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clear(sessionKey) {
|
|
65
|
+
queues.delete(sessionKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Format the drained messages as the additionalContext payload that
|
|
69
|
+
// Claude trusts. Multiple messages are joined with a blank line so
|
|
70
|
+
// the model sees them as a sequence within a single channel tag.
|
|
71
|
+
function formatForHook(messages) {
|
|
72
|
+
if (!messages || messages.length === 0) return null;
|
|
73
|
+
const body = messages.join('\n\n');
|
|
74
|
+
return `<channel source="user-followup">\n${body}\n</channel>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { append, drain, size, clear, formatForHook };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the PostToolBatch hook callback that drains the buffer for
|
|
82
|
+
* a specific sessionKey on each tool boundary. The callback shape
|
|
83
|
+
* matches `@anthropic-ai/claude-agent-sdk`'s HookCallback contract
|
|
84
|
+
* (sdk.d.ts:726-728): returns a HookJSONOutput; never throws.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {object} opts.buffer — the per-session buffer instance
|
|
88
|
+
* @param {string} opts.sessionKey — closure-bound at Query spawn time
|
|
89
|
+
* @param {(kind: string, detail: object) => void} [opts.logEvent]
|
|
90
|
+
* — optional events.table emitter; called when a drain produces
|
|
91
|
+
* non-empty output, with kind='autosteer-hook-drained'.
|
|
92
|
+
* @param {string|null} [opts.chatId] — for the logEvent payload only.
|
|
93
|
+
* @param {object} [opts.logger] — for error logging (must have .error).
|
|
94
|
+
*
|
|
95
|
+
* @returns {async () => Promise<HookJSONOutput>}
|
|
96
|
+
*/
|
|
97
|
+
function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = null, logger = console } = {}) {
|
|
98
|
+
if (!buffer) throw new TypeError('buffer required');
|
|
99
|
+
if (!sessionKey) throw new TypeError('sessionKey required');
|
|
100
|
+
return async () => {
|
|
101
|
+
try {
|
|
102
|
+
const drained = buffer.drain(sessionKey);
|
|
103
|
+
if (drained.length === 0) return { continue: true };
|
|
104
|
+
const additionalContext = buffer.formatForHook(drained);
|
|
105
|
+
if (typeof logEvent === 'function') {
|
|
106
|
+
try {
|
|
107
|
+
logEvent('autosteer-hook-drained', {
|
|
108
|
+
chat_id: chatId,
|
|
109
|
+
session_key: sessionKey,
|
|
110
|
+
message_count: drained.length,
|
|
111
|
+
});
|
|
112
|
+
} catch { /* logger errors must not break the hook */ }
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
continue: true,
|
|
116
|
+
hookSpecificOutput: {
|
|
117
|
+
hookEventName: 'PostToolBatch',
|
|
118
|
+
additionalContext,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger?.error?.(`[${sessionKey}] PostToolBatch hook error: ${err?.message || err}`);
|
|
123
|
+
// Never throw out of a hook — the SDK may treat it as a hard
|
|
124
|
+
// fail (`stop_hook_prevented` result subtype). Drop the
|
|
125
|
+
// queued messages on the floor; the user can re-send.
|
|
126
|
+
return { continue: true };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { createAutosteerBuffer, makePostToolBatchHook };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical-JSON stringification for chat_tool_decisions dedup.
|
|
3
|
+
*
|
|
4
|
+
* Used by polygram.js's canUseTool flow (rc.6 Phase 2 step 6):
|
|
5
|
+
* - lookup key for `chat_tool_decisions match_type='exact'`
|
|
6
|
+
* - input_pattern stored on "Always allow / Always deny" clicks
|
|
7
|
+
*
|
|
8
|
+
* Why canonical: Claude can reorder JSON keys between retries of
|
|
9
|
+
* the same tool call (different SDK versions, different temperature
|
|
10
|
+
* sampling). Without canonicalisation, the dedup digest would
|
|
11
|
+
* differ for semantically-identical calls and the user would see
|
|
12
|
+
* the same approval card twice (v4 plan §6.6 ship-breaker M8
|
|
13
|
+
* mitigation).
|
|
14
|
+
*
|
|
15
|
+
* Properties:
|
|
16
|
+
* - Keys sorted alphabetically at every nesting level
|
|
17
|
+
* - Arrays preserve order (only object keys are sorted)
|
|
18
|
+
* - No whitespace in output
|
|
19
|
+
* - null / undefined / primitive inputs round-trip via JSON.stringify
|
|
20
|
+
*
|
|
21
|
+
* NOT a full JSON canonicalisation spec (RFC 8785 / I-D
|
|
22
|
+
* cyberphone-json-canonicalization-scheme); we don't normalise
|
|
23
|
+
* number representations (1.0 vs 1, exponents) or string escapes.
|
|
24
|
+
* Sufficient for SDK-shaped tool inputs which are well-formed JSON
|
|
25
|
+
* objects with string keys.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
function canonicalizeToolInput(input) {
|
|
31
|
+
if (input == null || typeof input !== 'object') {
|
|
32
|
+
return JSON.stringify(input);
|
|
33
|
+
}
|
|
34
|
+
const sortRec = (v) => {
|
|
35
|
+
if (Array.isArray(v)) return v.map(sortRec);
|
|
36
|
+
if (v == null || typeof v !== 'object') return v;
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
return JSON.stringify(sortRec(input));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { canonicalizeToolInput };
|