polygram 0.7.9 → 0.8.0-rc.1
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 +169 -0
- package/lib/approval-waiters.js +194 -0
- package/lib/db.js +93 -7
- package/lib/process-manager-sdk.js +940 -0
- package/migrations/010-tool-use-id.sql +62 -0
- package/package.json +2 -1
- package/polygram.js +662 -18
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0-rc.1",
|
|
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",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chat agent loader for the SDK migration (Phase 1 step 14 /
|
|
3
|
+
* v4 plan §6.5.5).
|
|
4
|
+
*
|
|
5
|
+
* Background: today's CLI pm passes `--agent <name>` on spawn; the
|
|
6
|
+
* Claude CLI then loads that agent's directory under
|
|
7
|
+
* `~/.claude/agents/<name>/` (system prompt from `CLAUDE.md`,
|
|
8
|
+
* skills, mcpServers from settings.json). Phase 0 gate 15 is DEFER —
|
|
9
|
+
* the SDK's `Options.agents` is for in-memory subagent definitions
|
|
10
|
+
* (the Task tool), NOT a "run THIS query AS this agent" mechanism.
|
|
11
|
+
*
|
|
12
|
+
* This module provides a polygram-side loader so buildSdkOptions
|
|
13
|
+
* can compose the per-chat agent's settings into the chat's
|
|
14
|
+
* SdkOptions: read the agent's CLAUDE.md (system prompt), enumerate
|
|
15
|
+
* its skills, pick up its mcpServers from settings.json. Then merge
|
|
16
|
+
* into the per-chat SdkOptions with chat-level overrides taking
|
|
17
|
+
* precedence (chatConfig wins over agent wins over defaults).
|
|
18
|
+
*
|
|
19
|
+
* Used by `polygram.js` `buildSdkOptions(sessionKey, ctx)` —
|
|
20
|
+
* Phase 1 step 14.
|
|
21
|
+
*
|
|
22
|
+
* Cache: agentName → resolved AgentBundle. Invalidated on SIGHUP
|
|
23
|
+
* (callable via `clearCache()`). Phase 5 acceptance includes "agent
|
|
24
|
+
* config edits don't require daemon restart" — but for 0.8.0
|
|
25
|
+
* initial release, restart-on-edit is acceptable; clearCache hook
|
|
26
|
+
* is forward-compat.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
const cache = new Map(); // agentName → AgentBundle
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load an agent bundle from disk.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} agentName — e.g. 'shumabit-finance'
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {string} [opts.homeDir] — defaults to process.env.HOME.
|
|
42
|
+
* Resolves agent at `${homeDir}/.claude/agents/${agentName}/`.
|
|
43
|
+
* @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
|
+
*/
|
|
53
|
+
function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
|
|
54
|
+
if (cache.has(agentName)) return cache.get(agentName);
|
|
55
|
+
|
|
56
|
+
const agentDir = path.join(homeDir, '.claude', 'agents', agentName);
|
|
57
|
+
if (!fs.existsSync(agentDir)) {
|
|
58
|
+
throw Object.assign(
|
|
59
|
+
new Error(`agent not found: ${agentName} (looked in ${agentDir})`),
|
|
60
|
+
{ code: 'AGENT_NOT_FOUND', agentDir },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
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
|
+
let systemPrompt = null;
|
|
69
|
+
for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
|
|
70
|
+
const p = path.join(agentDir, fname);
|
|
71
|
+
if (fs.existsSync(p)) {
|
|
72
|
+
try {
|
|
73
|
+
systemPrompt = fs.readFileSync(p, 'utf8');
|
|
74
|
+
break;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logger.error?.(`[agent-loader] reading ${p}: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Settings: optional `settings.json` for per-agent overrides
|
|
82
|
+
// (mcpServers, model, effort defaults, etc.).
|
|
83
|
+
let settings = {};
|
|
84
|
+
const settingsPath = path.join(agentDir, 'settings.json');
|
|
85
|
+
if (fs.existsSync(settingsPath)) {
|
|
86
|
+
try {
|
|
87
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger.error?.(`[agent-loader] parsing ${settingsPath}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Skills: enumerate `${agentDir}/skills/*` directories. SDK's
|
|
94
|
+
// `Options.skills` accepts a string[] of skill names.
|
|
95
|
+
const skillsDir = path.join(agentDir, 'skills');
|
|
96
|
+
let skills = [];
|
|
97
|
+
if (fs.existsSync(skillsDir)) {
|
|
98
|
+
try {
|
|
99
|
+
skills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
100
|
+
.filter((d) => d.isDirectory())
|
|
101
|
+
.map((d) => d.name);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
logger.error?.(`[agent-loader] enumerating ${skillsDir}: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const mcpServers = settings.mcpServers ?? {};
|
|
108
|
+
|
|
109
|
+
const bundle = {
|
|
110
|
+
agentName,
|
|
111
|
+
agentDir,
|
|
112
|
+
systemPrompt,
|
|
113
|
+
skills,
|
|
114
|
+
mcpServers,
|
|
115
|
+
// Pass through extra settings for callers that want them
|
|
116
|
+
// (e.g. agent-level model/effort defaults).
|
|
117
|
+
raw: settings,
|
|
118
|
+
};
|
|
119
|
+
cache.set(agentName, bundle);
|
|
120
|
+
return bundle;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compose a chat's final SdkOptions from defaults + agent + per-chat
|
|
125
|
+
* overrides. Precedence: chatConfig > agent > defaults.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} chatConfig — config.chats[chatId].
|
|
128
|
+
* @param {AgentBundle|null} agentBundle — null if chat has no agent.
|
|
129
|
+
* @param {object} defaults — config.defaults.
|
|
130
|
+
*
|
|
131
|
+
* @returns {object} SdkOptions for `query({ options: ... })`.
|
|
132
|
+
*/
|
|
133
|
+
function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
|
|
134
|
+
// Start with defaults — these are the lowest-priority.
|
|
135
|
+
const opts = { ...defaults };
|
|
136
|
+
|
|
137
|
+
// Layer agent on top.
|
|
138
|
+
if (agentBundle) {
|
|
139
|
+
if (agentBundle.systemPrompt) opts.systemPrompt = agentBundle.systemPrompt;
|
|
140
|
+
if (agentBundle.skills?.length) opts.skills = agentBundle.skills;
|
|
141
|
+
if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
|
|
142
|
+
opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
|
|
143
|
+
}
|
|
144
|
+
// Agent-level model/effort/etc — only if chatConfig doesn't
|
|
145
|
+
// override.
|
|
146
|
+
for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
|
|
147
|
+
if (agentBundle.raw?.[key] != null && chatConfig[key] == null) {
|
|
148
|
+
opts[key] = agentBundle.raw[key];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Chat-level overrides (highest priority).
|
|
154
|
+
for (const [k, v] of Object.entries(chatConfig)) {
|
|
155
|
+
if (v == null) continue;
|
|
156
|
+
// Don't override the spread system-prompt with `agent` config
|
|
157
|
+
// string — that's a polygram concept, not an SdkOptions field.
|
|
158
|
+
if (k === 'agent') continue;
|
|
159
|
+
opts[k] = v;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return opts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function clearCache() {
|
|
166
|
+
cache.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { loadAgent, composeSdkOptions, clearCache, _cache: cache };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parked-Promise Map for canUseTool's async user-approval flow.
|
|
3
|
+
*
|
|
4
|
+
* Per v4 plan §6.5.3 / Phase 1 step 8.
|
|
5
|
+
*
|
|
6
|
+
* Background: under SDK migration, canUseTool is an in-process
|
|
7
|
+
* callback (replaces today's `bin/approval-hook.js` IPC). When a
|
|
8
|
+
* gated tool fires, polygram posts a Telegram inline-keyboard card
|
|
9
|
+
* to the admin chat and PARKS a Promise that resolves on user click.
|
|
10
|
+
* The SDK awaits that Promise — so the in-flight tool sleeps until
|
|
11
|
+
* the user decides.
|
|
12
|
+
*
|
|
13
|
+
* This module owns the waiter Map. Five cleanup paths are wired:
|
|
14
|
+
* 1. resolveByClick(toolUseId, decision) — user pressed a button
|
|
15
|
+
* 2. signal abort — SDK called Query.interrupt() / Query.close();
|
|
16
|
+
* AbortSignal fires → Promise rejects with code:'ABORTED'
|
|
17
|
+
* 3. timeout — periodic sweeper rejects waiters parked > timeoutMs
|
|
18
|
+
* 4. rejectAllForSession(sessionKey) — pm.resetSession or kill
|
|
19
|
+
* 5. shutdown — daemon SIGTERM; reject all
|
|
20
|
+
*
|
|
21
|
+
* Memory bound: MAX_WAITERS (200). Park beyond cap throws a typed
|
|
22
|
+
* error so the caller can return `{behavior:'deny'}` to the SDK
|
|
23
|
+
* instead of accumulating garbage.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_WAITERS = 200;
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 60_000; // 60s; matches OpenClaw cancel window
|
|
30
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 5_000;
|
|
31
|
+
|
|
32
|
+
function createApprovalWaiters({
|
|
33
|
+
logger = console,
|
|
34
|
+
maxWaiters = DEFAULT_MAX_WAITERS,
|
|
35
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
36
|
+
sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
|
|
37
|
+
} = {}) {
|
|
38
|
+
// toolUseId → entry { resolve, reject, signal, sigCleanup,
|
|
39
|
+
// parkedAt, sessionKey }
|
|
40
|
+
const waiters = new Map();
|
|
41
|
+
let sweepTimer = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Park the canUseTool Promise; return a Promise that resolves on
|
|
45
|
+
* user click / rejects on signal-abort / timeout / shutdown.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} args
|
|
48
|
+
* @param {string} args.toolUseId — SDK opts.toolUseID. Required.
|
|
49
|
+
* @param {string} args.sessionKey — for rejectAllForSession routing.
|
|
50
|
+
* @param {AbortSignal} [args.signal] — opts.signal from canUseTool.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<PermissionResult>}
|
|
53
|
+
* @throws {Error{code:'WAITER_CAP'}} if cap exceeded.
|
|
54
|
+
*/
|
|
55
|
+
function park({ toolUseId, sessionKey, signal }) {
|
|
56
|
+
if (!toolUseId) {
|
|
57
|
+
throw Object.assign(new Error('toolUseId required'),
|
|
58
|
+
{ code: 'NO_TOOL_USE_ID' });
|
|
59
|
+
}
|
|
60
|
+
if (waiters.size >= maxWaiters) {
|
|
61
|
+
logger.error?.(`[approval-waiters] cap reached (${maxWaiters}); rejecting`);
|
|
62
|
+
throw Object.assign(
|
|
63
|
+
new Error(`approval waiter cap exceeded (${maxWaiters})`),
|
|
64
|
+
{ code: 'WAITER_CAP' },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (waiters.has(toolUseId)) {
|
|
68
|
+
// Concurrent canUseTool with same toolUseID — SDK doesn't
|
|
69
|
+
// typically retry the same call, but handle defensively by
|
|
70
|
+
// resolving the old one with a deny first.
|
|
71
|
+
logger.error?.(`[approval-waiters] duplicate toolUseId ${toolUseId}; abandoning prior waiter`);
|
|
72
|
+
const prior = waiters.get(toolUseId);
|
|
73
|
+
prior.reject(Object.assign(new Error('superseded'), { code: 'SUPERSEDED' }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
// signal-abort cleanup wired here so signal-fires always
|
|
78
|
+
// unparks the waiter, even if user click never arrives.
|
|
79
|
+
const sigCleanup = signal
|
|
80
|
+
? () => {
|
|
81
|
+
const e = waiters.get(toolUseId);
|
|
82
|
+
if (e) {
|
|
83
|
+
waiters.delete(toolUseId);
|
|
84
|
+
e.reject(Object.assign(new Error('aborted'), { code: 'ABORTED' }));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
: null;
|
|
88
|
+
if (signal && sigCleanup) {
|
|
89
|
+
signal.addEventListener('abort', sigCleanup, { once: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
waiters.set(toolUseId, {
|
|
93
|
+
resolve: (decision) => {
|
|
94
|
+
if (signal && sigCleanup) {
|
|
95
|
+
try { signal.removeEventListener('abort', sigCleanup); }
|
|
96
|
+
catch { /* swallow */ }
|
|
97
|
+
}
|
|
98
|
+
waiters.delete(toolUseId);
|
|
99
|
+
resolve(decision);
|
|
100
|
+
},
|
|
101
|
+
reject: (err) => {
|
|
102
|
+
if (signal && sigCleanup) {
|
|
103
|
+
try { signal.removeEventListener('abort', sigCleanup); }
|
|
104
|
+
catch { /* swallow */ }
|
|
105
|
+
}
|
|
106
|
+
waiters.delete(toolUseId);
|
|
107
|
+
reject(err);
|
|
108
|
+
},
|
|
109
|
+
signal,
|
|
110
|
+
parkedAt: Date.now(),
|
|
111
|
+
sessionKey,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Path 1: user clicked a button. `decision` is the
|
|
118
|
+
* SDK-shape PermissionResult.
|
|
119
|
+
*/
|
|
120
|
+
function resolveByClick(toolUseId, decision) {
|
|
121
|
+
const e = waiters.get(toolUseId);
|
|
122
|
+
if (!e) return false;
|
|
123
|
+
e.resolve(decision);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Path 4: pm.resetSession or kill. Reject every waiter whose
|
|
129
|
+
* sessionKey matches.
|
|
130
|
+
*/
|
|
131
|
+
function rejectAllForSession(sessionKey, code = 'RESET_SESSION') {
|
|
132
|
+
let count = 0;
|
|
133
|
+
for (const [id, e] of [...waiters.entries()]) {
|
|
134
|
+
if (e.sessionKey === sessionKey) {
|
|
135
|
+
e.reject(Object.assign(new Error('session reset'), { code }));
|
|
136
|
+
count++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return count;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Path 5: daemon shutdown. Reject every waiter.
|
|
144
|
+
*/
|
|
145
|
+
function rejectAll(code = 'DAEMON_SHUTDOWN') {
|
|
146
|
+
let count = 0;
|
|
147
|
+
for (const [id, e] of [...waiters.entries()]) {
|
|
148
|
+
e.reject(Object.assign(new Error('daemon shutdown'), { code }));
|
|
149
|
+
count++;
|
|
150
|
+
}
|
|
151
|
+
return count;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Path 3: timeout sweeper. Periodically reject waiters parked
|
|
156
|
+
* longer than timeoutMs.
|
|
157
|
+
*/
|
|
158
|
+
function startTimeoutSweeper() {
|
|
159
|
+
if (sweepTimer) return;
|
|
160
|
+
const sweep = () => {
|
|
161
|
+
const cutoff = Date.now() - timeoutMs;
|
|
162
|
+
for (const [id, e] of [...waiters.entries()]) {
|
|
163
|
+
if (e.parkedAt < cutoff) {
|
|
164
|
+
e.reject(Object.assign(new Error('approval timeout'),
|
|
165
|
+
{ code: 'TIMEOUT' }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
sweepTimer = setInterval(sweep, sweepIntervalMs);
|
|
170
|
+
sweepTimer.unref?.();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopTimeoutSweeper() {
|
|
174
|
+
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
park,
|
|
179
|
+
resolveByClick,
|
|
180
|
+
rejectAllForSession,
|
|
181
|
+
rejectAll,
|
|
182
|
+
startTimeoutSweeper,
|
|
183
|
+
stopTimeoutSweeper,
|
|
184
|
+
get size() { return waiters.size; },
|
|
185
|
+
// Test introspection only:
|
|
186
|
+
_waiters: waiters,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
createApprovalWaiters,
|
|
192
|
+
DEFAULT_MAX_WAITERS,
|
|
193
|
+
DEFAULT_TIMEOUT_MS,
|
|
194
|
+
};
|
package/lib/db.js
CHANGED
|
@@ -8,13 +8,18 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const Database = require('better-sqlite3');
|
|
10
10
|
|
|
11
|
-
// 0.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
11
|
+
// 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
|
|
12
|
+
// 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
|
|
13
|
+
// SDK canUseTool stable per-call ID + chat_tool_decisions table for
|
|
14
|
+
// "always allow / always deny" persistence under the new in-process
|
|
15
|
+
// approval flow).
|
|
16
|
+
//
|
|
17
|
+
// 0.7.8 (history): bumped from 8 → 9 to fix a regression where 0.7.6
|
|
18
|
+
// added migration 009-turn-metrics.sql but forgot to bump
|
|
19
|
+
// SCHEMA_VERSION; the early-return on line ~42 then skipped the
|
|
20
|
+
// migration loop on any DB already at user_version=8 → turn_metrics
|
|
21
|
+
// table never created → INSERT prepare at startup crashed polygram.
|
|
22
|
+
const SCHEMA_VERSION = 10;
|
|
18
23
|
|
|
19
24
|
// Sentinel `error` value for outbound rows whose API call may or may not
|
|
20
25
|
// have reached Telegram. markStalePending writes it; hasOutboundReplyTo
|
|
@@ -188,6 +193,35 @@ function wrap(db) {
|
|
|
188
193
|
)
|
|
189
194
|
`);
|
|
190
195
|
|
|
196
|
+
// 0.8.0 Phase 1 step 8 — chat_tool_decisions persistence for the
|
|
197
|
+
// SDK canUseTool flow. Queried at the START of canUseTool to
|
|
198
|
+
// short-circuit "always allow / always deny" decisions before
|
|
199
|
+
// posting a Telegram inline-keyboard card. Migration 010 created
|
|
200
|
+
// the table; queries here. See v4 plan §6.5.4.
|
|
201
|
+
const lookupChatToolDecisionsStmt = db.prepare(`
|
|
202
|
+
SELECT match_type, input_pattern, decision, expires_ts
|
|
203
|
+
FROM chat_tool_decisions
|
|
204
|
+
WHERE bot_name = @bot_name
|
|
205
|
+
AND chat_id = @chat_id
|
|
206
|
+
AND tool_name = @tool_name
|
|
207
|
+
AND (expires_ts IS NULL OR expires_ts > @now)
|
|
208
|
+
`);
|
|
209
|
+
const insertChatToolDecisionStmt = db.prepare(`
|
|
210
|
+
INSERT INTO chat_tool_decisions (
|
|
211
|
+
bot_name, chat_id, tool_name, match_type,
|
|
212
|
+
input_pattern, decision,
|
|
213
|
+
issued_ts, issued_by_user_id, expires_ts
|
|
214
|
+
) VALUES (
|
|
215
|
+
@bot_name, @chat_id, @tool_name, @match_type,
|
|
216
|
+
@input_pattern, @decision,
|
|
217
|
+
@issued_ts, @issued_by_user_id, @expires_ts
|
|
218
|
+
)
|
|
219
|
+
`);
|
|
220
|
+
const deleteChatToolDecisionStmt = db.prepare(`
|
|
221
|
+
DELETE FROM chat_tool_decisions
|
|
222
|
+
WHERE bot_name = ? AND chat_id = ? AND id = ?
|
|
223
|
+
`);
|
|
224
|
+
|
|
191
225
|
const markStalePendingStmt = db.prepare(`
|
|
192
226
|
UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
|
|
193
227
|
WHERE status = 'pending' AND ts < ?
|
|
@@ -326,6 +360,58 @@ function wrap(db) {
|
|
|
326
360
|
});
|
|
327
361
|
},
|
|
328
362
|
|
|
363
|
+
/**
|
|
364
|
+
* 0.8.0 Phase 1 step 8 — chat_tool_decisions persistence.
|
|
365
|
+
*
|
|
366
|
+
* Look up "always allow / always deny" decisions for a tool
|
|
367
|
+
* call. Returns the FIRST matching decision (by id ASC) whose
|
|
368
|
+
* match_type accepts the canonical input. Pattern matching is
|
|
369
|
+
* done in-process here so the SQL query stays simple.
|
|
370
|
+
*
|
|
371
|
+
* Canonical input: keys sorted alphabetically, no whitespace.
|
|
372
|
+
* Done by the caller (canUseTool wrapper) — we accept the
|
|
373
|
+
* pre-canonicalised string as `canonical_input`.
|
|
374
|
+
*/
|
|
375
|
+
lookupChatToolDecision({ bot_name, chat_id, tool_name, canonical_input, now }) {
|
|
376
|
+
const rows = lookupChatToolDecisionsStmt.all({
|
|
377
|
+
bot_name: String(bot_name),
|
|
378
|
+
chat_id: String(chat_id),
|
|
379
|
+
tool_name: String(tool_name),
|
|
380
|
+
now: now || Date.now(),
|
|
381
|
+
});
|
|
382
|
+
for (const r of rows) {
|
|
383
|
+
if (r.match_type === 'exact') {
|
|
384
|
+
if (r.input_pattern === canonical_input) return r;
|
|
385
|
+
} else if (r.match_type === 'prefix') {
|
|
386
|
+
if (canonical_input?.startsWith?.(r.input_pattern)) return r;
|
|
387
|
+
} else if (r.match_type === 'regex') {
|
|
388
|
+
try {
|
|
389
|
+
if (new RegExp(r.input_pattern).test(canonical_input || '')) return r;
|
|
390
|
+
} catch { /* malformed regex — ignore */ }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
insertChatToolDecision(row) {
|
|
397
|
+
return insertChatToolDecisionStmt.run({
|
|
398
|
+
bot_name: String(row.bot_name),
|
|
399
|
+
chat_id: String(row.chat_id),
|
|
400
|
+
tool_name: String(row.tool_name),
|
|
401
|
+
match_type: row.match_type,
|
|
402
|
+
input_pattern: row.input_pattern,
|
|
403
|
+
decision: row.decision,
|
|
404
|
+
issued_ts: row.issued_ts || Date.now(),
|
|
405
|
+
issued_by_user_id: row.issued_by_user_id != null
|
|
406
|
+
? String(row.issued_by_user_id) : null,
|
|
407
|
+
expires_ts: row.expires_ts ?? null,
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
deleteChatToolDecision({ bot_name, chat_id, id }) {
|
|
412
|
+
return deleteChatToolDecisionStmt.run(String(bot_name), String(chat_id), id);
|
|
413
|
+
},
|
|
414
|
+
|
|
329
415
|
logConfigChange(row) {
|
|
330
416
|
return logConfigChangeStmt.run({
|
|
331
417
|
chat_id: String(row.chat_id),
|