hive-am 1.0.0 → 1.1.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/CHANGELOG.md +32 -1
- package/README.md +26 -4
- package/package.json +1 -1
- package/server/dist/api.js +86 -1
- package/server/dist/connections/addressing.js +87 -0
- package/server/dist/connections/channel-skill.js +50 -0
- package/server/dist/connections/fake.js +1 -0
- package/server/dist/connections/format.js +9 -1
- package/server/dist/connections/manager.js +19 -0
- package/server/dist/connections/prompt.js +8 -3
- package/server/dist/connections/rate.js +20 -0
- package/server/dist/connections/router.js +53 -21
- package/server/dist/connections/secrets.js +52 -0
- package/server/dist/connections/store.js +2 -0
- package/server/dist/connections/telegram.js +62 -11
- package/server/dist/cron.js +137 -0
- package/server/dist/history/claude.js +13 -0
- package/server/dist/index.js +8 -0
- package/server/dist/instructions.js +17 -11
- package/server/dist/providers/claude.js +2 -0
- package/server/dist/providers/kiro.js +2 -0
- package/server/dist/providers/opencode-server.js +164 -0
- package/server/dist/providers/opencode.js +62 -36
- package/server/dist/runtime.js +2 -1
- package/server/dist/schedules.js +241 -0
- package/server/dist/skills/defaults.js +83 -0
- package/server/dist/wake.js +152 -0
- package/server/dist/watch.js +207 -0
- package/server/mcp/dispatch.mjs +100 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +47 -39
- package/web/.next/app-path-routes-manifest.json +6 -5
- package/web/.next/build-manifest.json +2 -2
- package/web/.next/prerender-manifest.json +64 -40
- package/web/.next/routes-manifest.json +6 -0
- package/web/.next/server/app/_not-found/page.js +1 -1
- package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +3 -3
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +1 -1
- package/web/.next/server/app/agents/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +4 -4
- package/web/.next/server/app/connections/page.js +1 -1
- package/web/.next/server/app/connections/page.js.nft.json +1 -1
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +4 -4
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +4 -4
- package/web/.next/server/app/page.js +1 -1
- package/web/.next/server/app/page.js.nft.json +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +1 -1
- package/web/.next/server/app/relations/page.js.nft.json +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +4 -4
- package/web/.next/server/app/schedules/page.js +2 -0
- package/web/.next/server/app/schedules/page.js.nft.json +1 -0
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/schedules.html +1 -0
- package/web/.next/server/app/schedules.meta +15 -0
- package/web/.next/server/app/schedules.rsc +24 -0
- package/web/.next/server/app/sessions/page.js +1 -1
- package/web/.next/server/app/sessions/page.js.nft.json +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +4 -4
- package/web/.next/server/app/skills/page.js +1 -1
- package/web/.next/server/app/skills/page.js.nft.json +1 -1
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +4 -4
- package/web/.next/server/app/types/page.js +1 -1
- package/web/.next/server/app/types/page.js.nft.json +1 -1
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +4 -4
- package/web/.next/server/app-paths-manifest.json +6 -5
- package/web/.next/server/chunks/{149.js → 484.js} +2 -2
- package/web/.next/server/chunks/851.js +1 -1
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/_z2hBOHhwiR5ytdI_8alh/_buildManifest.js +1 -0
- package/web/.next/static/chunks/493-50d2adf9fd5ec680.js +1 -0
- package/web/.next/static/chunks/{351-65da8c1a6e25f93b.js → 589-8bf536f331de4269.js} +1 -1
- package/web/.next/static/chunks/app/agents/[id]/page-a8b7a43d2c18483f.js +1 -0
- package/web/.next/static/chunks/app/connections/page-477f36fcd5b13d78.js +1 -0
- package/web/.next/static/chunks/app/layout-a2d38e3de5911b1b.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-39befd15b3b20586.js +1 -0
- package/web/.next/static/css/da22f0402b1c40ae.css +1 -0
- package/web/.next/trace +23 -23
- package/web/.next/types/app/schedules/page.ts +84 -0
- package/web/.next/types/routes.d.ts +2 -1
- package/web/.next/types/validator.ts +9 -0
- package/web/.next/static/84BpRSahg2BTwk3mX6AG8/_buildManifest.js +0 -1
- package/web/.next/static/chunks/493-92266798be872b8f.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-5eaf094fec246f1b.js +0 -1
- package/web/.next/static/chunks/app/connections/page-c6afd90abd82d47a.js +0 -1
- package/web/.next/static/chunks/app/layout-c415d5a847cd94b3.js +0 -1
- package/web/.next/static/css/abe1a6216491f944.css +0 -1
- /package/web/.next/static/{84BpRSahg2BTwk3mX6AG8 → _z2hBOHhwiR5ytdI_8alh}/_ssgManifest.js +0 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { agents } from '../db.js';
|
|
2
2
|
import { liveTurn, queueDepth, sendTurn, stopAgent } from '../runtime.js';
|
|
3
|
+
import { calledByName as aliasCall, directedAtOther, firstNames } from './addressing.js';
|
|
4
|
+
import { allow } from './rate.js';
|
|
3
5
|
import { saveAttachments } from './files.js';
|
|
4
6
|
import { describeImages, validVision } from './vision.js';
|
|
5
7
|
import { channelPrompt } from './prompt.js';
|
|
6
8
|
import { connections, threadMessages, threads } from './store.js';
|
|
7
9
|
const MAX_INPUT = 8000;
|
|
8
10
|
const DEFAULT_RATE = 20; // messages per user per minute
|
|
11
|
+
const DEFAULT_CHATTER_PER_MINUTE = 10; // times per minute that chatter nobody aimed at the agent may wake it, per thread
|
|
12
|
+
/** Sent once when a turn aimed at the agent ended without a reply. */
|
|
13
|
+
const NUDGE = '[hive:channel] System reminder: your last turn ended without calling `channel_reply`, so the person received nothing. If you had an answer, send it now with `channel_reply` (if the tool says it is unknown, call it again: it was still connecting). If you chose not to answer, say so in one short sentence with `channel_reply`.';
|
|
14
|
+
export const EFFORTS = ['low', 'medium', 'high'];
|
|
9
15
|
const QUIET_MS = 4000; // group chatter is handed over once it pauses this long, so a burst is one turn
|
|
10
|
-
const
|
|
16
|
+
const LONG_TURN_MS = 45_000; // an unaddressed turn this long did real work: if it never replied, remind it
|
|
17
|
+
const QUIET_RETRIES = 15; // while the agent is busy the hand-over waits (about a minute); after this it is left as context for the next turn
|
|
11
18
|
const T = {
|
|
12
19
|
noAgent: { es: 'Esta conexión no tiene un agente vinculado.', en: 'This connection has no agent linked.' },
|
|
13
20
|
notAllowed: (id) => ({ es: `No tienes acceso. Pide que añadan tu id: ${id}`, en: `You don't have access. Ask to add your id: ${id}` }),
|
|
@@ -40,13 +47,13 @@ function tooFast(connId, user, limit) {
|
|
|
40
47
|
const told = new Set(); // users already told how to get access, so strangers can't make us spam them
|
|
41
48
|
const chatsOf = (c) => (Array.isArray(c.config.chats) ? c.config.chats : []);
|
|
42
49
|
const aliasesOf = (c) => (Array.isArray(c.config.aliases) ? c.config.aliases : []).map((a) => String(a).trim()).filter(Boolean);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return
|
|
50
|
+
/** Group chatter that speaks TO the agent by one of its aliases counts as aimed at it, even without @mention. */
|
|
51
|
+
const selfNames = (c) => { const n = (c.agent_id ? agents.get(c.agent_id)?.name : '') ?? ''; return n ? [n, n.split(/\s+/)[0]] : []; };
|
|
52
|
+
const calledByName = (c, text) => aliasCall([...aliasesOf(c), ...selfNames(c)], text);
|
|
53
|
+
/** The other humans of this thread, by first name (never the sender, the agent or its aliases). */
|
|
54
|
+
function otherPeople(threadId, sender, agentNames) {
|
|
55
|
+
const names = threadMessages.senders(threadId).filter((x) => x.id !== sender.userId).map((x) => x.name);
|
|
56
|
+
return firstNames(names, [sender.userName, ...agentNames]);
|
|
50
57
|
}
|
|
51
58
|
const quiet = new Map(); // thread id → chatter waiting for a pause
|
|
52
59
|
const clearQuiet = (threadId) => { const q = quiet.get(threadId); if (q) {
|
|
@@ -93,7 +100,7 @@ export async function handleInbound(connectionId, adapter, m) {
|
|
|
93
100
|
}
|
|
94
101
|
else {
|
|
95
102
|
if (addressed)
|
|
96
|
-
await adapter.busy(m.target, 'working').catch(() => undefined);
|
|
103
|
+
await adapter.busy(m.target, 'working', m.externalId).catch(() => undefined);
|
|
97
104
|
const { saved, failed } = await saveAttachments(agent.id, adapter, m);
|
|
98
105
|
if (validVision(conn.config.vision))
|
|
99
106
|
await describeImages(agent, conn.config.vision, saved);
|
|
@@ -103,8 +110,10 @@ export async function handleInbound(connectionId, adapter, m) {
|
|
|
103
110
|
if (failed.length && addressed)
|
|
104
111
|
await reply(say(conn, T.fileFailed(failed)));
|
|
105
112
|
}
|
|
106
|
-
if (!m.text.trim() && !m.files?.length)
|
|
107
|
-
|
|
113
|
+
if (!m.text.trim() && !m.files?.length) {
|
|
114
|
+
await adapter.busy(m.target, 'done', m.externalId).catch(() => undefined);
|
|
115
|
+
return;
|
|
116
|
+
} // only files, and none could be saved: nothing to hand over
|
|
108
117
|
}
|
|
109
118
|
if (m.command) {
|
|
110
119
|
const { name, args } = m.command;
|
|
@@ -140,6 +149,8 @@ export async function handleInbound(connectionId, adapter, m) {
|
|
|
140
149
|
addressed = true;
|
|
141
150
|
}
|
|
142
151
|
if (!addressed) {
|
|
152
|
+
// People talking to each other: the agent still listens (it can correct a mistake or warn of a danger) but the header says it is for somebody else, and the bar to speak is higher.
|
|
153
|
+
m.directedAt = m.directedAt || directedAtOther(otherPeople(thread.id, m, [...selfNames(conn), ...aliasesOf(conn)]), m.text) || undefined;
|
|
143
154
|
// Heard, not called: it is kept for context, and the agent only wakes up once the chat pauses (never while muted).
|
|
144
155
|
if (thread.muted)
|
|
145
156
|
return;
|
|
@@ -163,13 +174,16 @@ function arm(connectionId, adapter, threadId, m, msgId, tries) {
|
|
|
163
174
|
arm(connectionId, adapter, threadId, m, msgId, tries + 1);
|
|
164
175
|
return;
|
|
165
176
|
}
|
|
177
|
+
// A flood of chatter must not wake the agent over and over: past the limit the message stays as context for the next turn (see `chatter_per_minute`).
|
|
178
|
+
if (!allow(`chatter:${thread.id}`, Number(conn.config.chatter_per_minute ?? DEFAULT_CHATTER_PER_MINUTE)))
|
|
179
|
+
return;
|
|
166
180
|
void deliver(conn, adapter, thread, m, msgId, false);
|
|
167
181
|
}, QUIET_MS);
|
|
168
182
|
timer.unref?.();
|
|
169
183
|
quiet.set(threadId, { timer, m, msgId, tries });
|
|
170
184
|
}
|
|
171
185
|
/** Hands one message (and anything unseen before it) to the agent and reports how the turn went. */
|
|
172
|
-
async function deliver(conn, adapter, thread, m, msgId, addressed) {
|
|
186
|
+
async function deliver(conn, adapter, thread, m, msgId, addressed, scheduled = false) {
|
|
173
187
|
const agent = conn.agent_id ? agents.get(conn.agent_id) : undefined;
|
|
174
188
|
if (!agent)
|
|
175
189
|
return;
|
|
@@ -186,39 +200,57 @@ async function deliver(conn, adapter, thread, m, msgId, addressed) {
|
|
|
186
200
|
return;
|
|
187
201
|
}
|
|
188
202
|
const ahead = queueDepth(agent.id) + (liveTurn(agent.id) ? 1 : 0);
|
|
189
|
-
if (addressed && ahead > 0)
|
|
203
|
+
if (addressed && ahead > 0 && !scheduled)
|
|
190
204
|
await reply(say(conn, T.queued(ahead)));
|
|
191
205
|
const unseen = threadMessages.unseen(thread.id, fresh.seen_id, msgId).map((r) => ({ name: r.user_name ?? r.user_id ?? '?', text: r.text, files: r.files ? JSON.parse(r.files) : undefined }));
|
|
192
206
|
threads.markSeen(thread.id, msgId);
|
|
193
|
-
const origin = { connectionId: conn.id, threadId: thread.id, externalKey: m.externalKey, platform: conn.kind, place: m.place, userName: m.userName, addressed, replied: false };
|
|
207
|
+
const origin = { connectionId: conn.id, threadId: thread.id, externalKey: m.externalKey, platform: conn.kind, place: m.place, userName: m.userName, userId: m.userId, group: !!m.group, mention: m.mention, addressed, replied: false, effort: EFFORTS.includes(conn.config.effort) ? conn.config.effort : undefined };
|
|
194
208
|
if (addressed)
|
|
195
|
-
await adapter.busy(m.target, 'working').catch(() => undefined);
|
|
196
|
-
const
|
|
209
|
+
await adapter.busy(m.target, 'working', m.externalId).catch(() => undefined);
|
|
210
|
+
const startedAt = Date.now();
|
|
211
|
+
let res = await sendTurn(agent.id, channelPrompt(conn.kind, m, { addressed, muted: fresh.muted, unseen }), 'user', undefined, undefined, origin);
|
|
212
|
+
// Chatter nobody aimed at the agent may be left unanswered, but a turn that worked for a while and ended without `channel_reply`
|
|
213
|
+
// produced a result nobody will ever see (the agent wrote it as plain text). Remind it once.
|
|
214
|
+
if (!addressed && res.ok && !origin.replied && !threads.get(thread.id)?.muted && Date.now() - startedAt > LONG_TURN_MS) {
|
|
215
|
+
res = await sendTurn(agent.id, NUDGE, 'user', undefined, undefined, origin);
|
|
216
|
+
}
|
|
217
|
+
// Someone talked to the agent and it ended without answering: usually a tool that failed at the start of the turn and a model that gave up.
|
|
218
|
+
// One reminder (same conversation, same origin) fixes that; staying quiet on purpose (muted thread) is respected.
|
|
219
|
+
if (addressed && res.ok && !origin.replied && !threads.get(thread.id)?.muted) {
|
|
220
|
+
res = await sendTurn(agent.id, NUDGE, 'user', undefined, undefined, origin);
|
|
221
|
+
}
|
|
197
222
|
const onSilent = conn.config.on_silent ?? 'notice';
|
|
198
223
|
if (!res.ok) {
|
|
199
224
|
if (!addressed) {
|
|
200
225
|
console.error(`[connections] ${conn.name}: a turn on group chatter failed: ${res.error}`);
|
|
201
226
|
return;
|
|
202
227
|
}
|
|
203
|
-
await adapter.busy(m.target, 'failed').catch(() => undefined);
|
|
228
|
+
await adapter.busy(m.target, 'failed', m.externalId).catch(() => undefined);
|
|
204
229
|
if (res.error && res.error !== 'Stopped')
|
|
205
230
|
await reply(say(conn, T.failed(res.error)));
|
|
206
231
|
}
|
|
207
232
|
else if (origin.replied || !addressed) {
|
|
208
233
|
// Staying quiet on chatter nobody aimed at the agent is a normal outcome.
|
|
209
234
|
if (addressed)
|
|
210
|
-
await adapter.busy(m.target, 'done').catch(() => undefined);
|
|
235
|
+
await adapter.busy(m.target, 'done', m.externalId).catch(() => undefined);
|
|
211
236
|
}
|
|
212
237
|
else {
|
|
213
238
|
if (onSilent === 'send_text' && res.text) {
|
|
214
239
|
await reply(res.text);
|
|
215
|
-
await adapter.busy(m.target, 'done').catch(() => undefined);
|
|
240
|
+
await adapter.busy(m.target, 'done', m.externalId).catch(() => undefined);
|
|
216
241
|
}
|
|
217
242
|
else if (onSilent === 'notice') {
|
|
218
|
-
await adapter.busy(m.target, 'failed').catch(() => undefined);
|
|
243
|
+
await adapter.busy(m.target, 'failed', m.externalId).catch(() => undefined);
|
|
219
244
|
await reply(say(conn, T.silent));
|
|
220
245
|
}
|
|
221
246
|
else
|
|
222
|
-
await adapter.busy(m.target, 'done').catch(() => undefined);
|
|
247
|
+
await adapter.busy(m.target, 'done', m.externalId).catch(() => undefined);
|
|
223
248
|
}
|
|
224
249
|
}
|
|
250
|
+
/** A message the agent asked to receive later (a wake-up, a schedule): it reaches the agent like a message of that thread, so it can answer there. */
|
|
251
|
+
export async function deliverScheduled(conn, adapter, thread, text, mention) {
|
|
252
|
+
const m = {
|
|
253
|
+
externalId: `sched-${Date.now()}`, externalKey: thread.external_key, userId: '', userName: 'hive-am (scheduled)', place: thread.title || 'chat', target: thread.target, addressed: true, text, mention,
|
|
254
|
+
};
|
|
255
|
+
await deliver(conn, adapter, thread, m, threadMessages.lastId(thread.id), true, true);
|
|
256
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { connections } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Last line of defence for text an agent sends to a chat. The instructions tell the agent never to share secrets, but a model can be
|
|
4
|
+
* talked into it (by anyone in a group, or by an admin who does not realise what the output holds), so the text is checked here
|
|
5
|
+
* before it leaves this machine. It looks for well-known credential shapes and for the exact secrets this server knows about.
|
|
6
|
+
* It cannot recognise every secret: it is a net under the rule, not a replacement for it.
|
|
7
|
+
*/
|
|
8
|
+
const SHAPES = [
|
|
9
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/, 'a private key'],
|
|
10
|
+
[/\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/, 'a Telegram bot token'],
|
|
11
|
+
[/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, 'an AWS access key'],
|
|
12
|
+
[/\bgh[pousr]_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/, 'a GitHub token'],
|
|
13
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'a Slack token'],
|
|
14
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/, 'a Stripe key'],
|
|
15
|
+
[/\bsk-[A-Za-z0-9_-]{32,}\b/, 'an API key'],
|
|
16
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
|
|
17
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, 'a JWT'],
|
|
18
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/, 'a bearer token'],
|
|
19
|
+
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]{3,}@[^\s]+/i, 'a URL with a password in it'],
|
|
20
|
+
];
|
|
21
|
+
/** `NAME=value` / `name: value` where the name says it is a secret and the value looks real (not a placeholder). */
|
|
22
|
+
const ASSIGN = /\b([A-Za-z0-9_.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key|credential|auth)[A-Za-z0-9_.-]*)\s*["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
23
|
+
const PLACEHOLDER = /^(<.*>|\{.*\}|\$\{?\w+\}?|\*+|x{4,}|\.{3,}|your[_-]|example|changeme|redacted|none|null|undefined|true|false|process\.env|env\.)/i;
|
|
24
|
+
const SECRET_ENV = /(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
|
|
25
|
+
/** Values that are secret because this server holds them: connection tokens and secret-looking environment variables. */
|
|
26
|
+
function knownSecrets() {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const c of connections.list())
|
|
29
|
+
for (const k of ['token', 'signing_secret', 'app_token', 'bot_token']) {
|
|
30
|
+
const v = c.config?.[k];
|
|
31
|
+
if (typeof v === 'string' && v.length >= 8)
|
|
32
|
+
out.add(v);
|
|
33
|
+
}
|
|
34
|
+
for (const [k, v] of Object.entries(process.env))
|
|
35
|
+
if (v && v.length >= 12 && SECRET_ENV.test(k))
|
|
36
|
+
out.add(v);
|
|
37
|
+
return [...out];
|
|
38
|
+
}
|
|
39
|
+
/** What kind of secret the text seems to contain, or null when it looks fine. Never returns the secret itself. */
|
|
40
|
+
export function findSecret(text) {
|
|
41
|
+
for (const s of knownSecrets())
|
|
42
|
+
if (text.includes(s))
|
|
43
|
+
return 'a secret held by hive-am or this machine';
|
|
44
|
+
for (const [re, what] of SHAPES)
|
|
45
|
+
if (re.test(text))
|
|
46
|
+
return what;
|
|
47
|
+
for (const m of text.matchAll(ASSIGN))
|
|
48
|
+
if (!PLACEHOLDER.test(m[2]))
|
|
49
|
+
return `the value of "${m[1]}"`;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export const SECRET_REFUSAL = (what) => `Not sent: the message seems to contain ${what}. Secrets (passwords, tokens, keys, connection strings) are never shared in a chat, not even with an admin or in a private chat. Tell the person you cannot share it and, if it helps, describe where it lives or how they can read it themselves, without writing its value.`;
|
|
@@ -97,6 +97,8 @@ export const threadMessages = {
|
|
|
97
97
|
/** Remembers the files saved for an inbound message, so they can be listed later as context. */
|
|
98
98
|
setFiles: (threadId, externalId, files) => { db.prepare("UPDATE thread_messages SET files=? WHERE thread_id=? AND direction='in' AND external_id=?").run(JSON.stringify(files), threadId, externalId); },
|
|
99
99
|
/** Id of the newest message of the thread (0 when none). */
|
|
100
|
+
/** The people who wrote in a thread recently (not the agent). */
|
|
101
|
+
senders: (threadId) => db.prepare("SELECT DISTINCT user_id AS id, user_name AS name FROM (SELECT user_id, user_name FROM thread_messages WHERE thread_id=? AND direction='in' AND user_name IS NOT NULL ORDER BY id DESC LIMIT 300)").all(threadId),
|
|
100
102
|
lastId: (threadId) => db.prepare('SELECT MAX(id) AS m FROM thread_messages WHERE thread_id=?').get(threadId).m ?? 0,
|
|
101
103
|
/** Messages people wrote that the agent has not been shown yet (after `afterId`, before `beforeId`), oldest first. */
|
|
102
104
|
unseen: (threadId, afterId, beforeId, limit = 30) => db.prepare(`SELECT * FROM (SELECT * FROM thread_messages WHERE thread_id=? AND direction='in' AND id>? AND id<? ORDER BY id DESC LIMIT ?) ORDER BY id`).all(threadId, afterId, beforeId, limit),
|
|
@@ -4,6 +4,7 @@ import { mdToTelegramHtml, splitMessage } from './format.js';
|
|
|
4
4
|
import { cursors } from './store.js';
|
|
5
5
|
const MAX_PART = 3500; // Telegram allows 4096; leave room for the HTML tags added after splitting
|
|
6
6
|
const TYPING_EVERY_MS = 4500; // «typing…» lasts ~5 s
|
|
7
|
+
const TYPING_MAX_MS = 20 * 60_000; // safety net: never pulse for longer than this, whatever happens to the turn
|
|
7
8
|
const MIN_GAP_MS = 350; // stay well under Telegram's ~1 message/second per chat
|
|
8
9
|
const ALBUM_WAIT_MS = 1200; // the photos of an album arrive as separate messages: wait for the rest
|
|
9
10
|
class TelegramError extends Error {
|
|
@@ -52,6 +53,7 @@ export class TelegramAdapter {
|
|
|
52
53
|
stopped = false;
|
|
53
54
|
abort = new AbortController();
|
|
54
55
|
typing = new Map();
|
|
56
|
+
typingWarned = 0;
|
|
55
57
|
lastSend = new Map();
|
|
56
58
|
albums = new Map();
|
|
57
59
|
constructor(conn) {
|
|
@@ -82,13 +84,15 @@ export class TelegramAdapter {
|
|
|
82
84
|
this.stopped = true;
|
|
83
85
|
this.abort.abort();
|
|
84
86
|
for (const t of this.typing.values())
|
|
85
|
-
clearInterval(t);
|
|
87
|
+
clearInterval(t.timer);
|
|
86
88
|
this.typing.clear();
|
|
87
89
|
for (const a of this.albums.values())
|
|
88
90
|
clearTimeout(a.timer);
|
|
89
91
|
this.albums.clear();
|
|
90
92
|
this.state = { state: 'stopped' };
|
|
91
93
|
}
|
|
94
|
+
/** A link to the person's account: it notifies them in a group even without a @username. */
|
|
95
|
+
mention(u) { return `[${u.name.replace(/[\[\]]/g, '')}](tg://user?id=${u.id})`; }
|
|
92
96
|
status() { return this.state; }
|
|
93
97
|
/** «Connected», with a warning when the connection listens to whole groups but Telegram's privacy mode hides them. */
|
|
94
98
|
connected() {
|
|
@@ -153,10 +157,29 @@ export class TelegramAdapter {
|
|
|
153
157
|
if (cmd && cmd[2] && cmd[2].toLowerCase() !== this.me.username.toLowerCase())
|
|
154
158
|
return null; // a command for another bot
|
|
155
159
|
const mention = new RegExp(`@${this.me.username}\\b`, 'i');
|
|
156
|
-
|
|
160
|
+
// In a forum topic every message carries a `reply_to_message` that is just the topic's first message (not a real reply to someone).
|
|
161
|
+
const realReply = msg.reply_to_message && !msg.reply_to_message.forum_topic_created && !(msg.is_topic_message && msg.reply_to_message.message_id === msg.message_thread_id) ? msg.reply_to_message : undefined;
|
|
162
|
+
const addressed = isPrivate || !!cmd || mention.test(text) || realReply?.from?.id === this.me.id;
|
|
157
163
|
text = text.replace(mention, '').trim();
|
|
158
164
|
if (!text && !attachments.length)
|
|
159
165
|
return null;
|
|
166
|
+
// Meant for another person of the chat: a reply to their message, or an @mention / text mention of them.
|
|
167
|
+
const raw = msg.text ?? msg.caption ?? '';
|
|
168
|
+
let directedAt;
|
|
169
|
+
const to = realReply?.from;
|
|
170
|
+
if (!isPrivate && to && !to.is_bot && to.id !== this.me.id && to.id !== msg.from?.id)
|
|
171
|
+
directedAt = to.username ?? to.first_name ?? String(to.id);
|
|
172
|
+
for (const e of (msg.entities ?? msg.caption_entities ?? [])) {
|
|
173
|
+
if (directedAt || isPrivate)
|
|
174
|
+
break;
|
|
175
|
+
if (e.type === 'text_mention' && e.user && !e.user.is_bot && e.user.id !== this.me.id)
|
|
176
|
+
directedAt = e.user.first_name ?? String(e.user.id);
|
|
177
|
+
else if (e.type === 'mention') {
|
|
178
|
+
const u = raw.slice(e.offset + 1, e.offset + e.length);
|
|
179
|
+
if (u && u.toLowerCase() !== this.me.username.toLowerCase())
|
|
180
|
+
directedAt = u;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
160
183
|
const topic = msg.is_topic_message && msg.message_thread_id !== undefined ? String(msg.message_thread_id) : undefined;
|
|
161
184
|
const from = msg.from ?? {};
|
|
162
185
|
return {
|
|
@@ -170,6 +193,7 @@ export class TelegramAdapter {
|
|
|
170
193
|
command: cmd ? { name: cmd[1].toLowerCase(), args: (cmd[3] ?? '').trim() } : undefined,
|
|
171
194
|
group: !isPrivate,
|
|
172
195
|
addressed,
|
|
196
|
+
...(directedAt ? { directedAt } : {}),
|
|
173
197
|
...(attachments.length ? { attachments } : {}),
|
|
174
198
|
};
|
|
175
199
|
}
|
|
@@ -236,6 +260,8 @@ export class TelegramAdapter {
|
|
|
236
260
|
}));
|
|
237
261
|
last = String(sent.message_id);
|
|
238
262
|
}
|
|
263
|
+
if (this.typing.has(`${to.chat}:${to.thread ?? ''}`))
|
|
264
|
+
this.ping(to); // a sent message cancels «typing…»; the agent is still working
|
|
239
265
|
return { externalId: last };
|
|
240
266
|
}
|
|
241
267
|
/** Keeps one chat's messages spaced out and honours Telegram's `retry_after` when it pushes back. */
|
|
@@ -257,18 +283,43 @@ export class TelegramAdapter {
|
|
|
257
283
|
}
|
|
258
284
|
}
|
|
259
285
|
}
|
|
260
|
-
|
|
286
|
+
/** One «typing…» pulse. A failure is logged (once a minute) instead of being swallowed, so a silent problem is visible. */
|
|
287
|
+
ping(to) {
|
|
288
|
+
void this.call('sendChatAction', { chat_id: to.chat, action: 'typing', ...(to.thread ? { message_thread_id: Number(to.thread) } : {}) }).catch((e) => {
|
|
289
|
+
if (Date.now() - this.typingWarned > 60_000) {
|
|
290
|
+
this.typingWarned = Date.now();
|
|
291
|
+
console.warn(`[connections] ${this.conn.name}: «typing…» failed: ${e.message}`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async busy(to, state, ref = '') {
|
|
261
296
|
const key = `${to.chat}:${to.thread ?? ''}`;
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
297
|
+
const cur = this.typing.get(key);
|
|
298
|
+
if (state === 'working') {
|
|
299
|
+
if (cur) {
|
|
300
|
+
cur.refs.add(ref);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.ping(to);
|
|
304
|
+
const since = Date.now();
|
|
305
|
+
const timer = setInterval(() => {
|
|
306
|
+
if (Date.now() - since > TYPING_MAX_MS) {
|
|
307
|
+
clearInterval(timer);
|
|
308
|
+
this.typing.delete(key);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.ping(to);
|
|
312
|
+
}, TYPING_EVERY_MS);
|
|
313
|
+
this.typing.set(key, { timer, refs: new Set([ref]) });
|
|
314
|
+
return;
|
|
266
315
|
}
|
|
267
|
-
if (
|
|
316
|
+
if (!cur)
|
|
268
317
|
return;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
318
|
+
cur.refs.delete(ref);
|
|
319
|
+
if (!cur.refs.size) {
|
|
320
|
+
clearInterval(cur.timer);
|
|
321
|
+
this.typing.delete(key);
|
|
322
|
+
}
|
|
272
323
|
}
|
|
273
324
|
async test(userIds) {
|
|
274
325
|
const me = await this.call('getMe');
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron expressions (5 fields: minute hour day-of-month month day-of-week) evaluated in a time zone.
|
|
3
|
+
* The next run is computed from calendar days and local times and then mapped to an instant, so daylight-saving changes behave:
|
|
4
|
+
* a local time that does not exist (the hour skipped in spring) is skipped, and one that happens twice (autumn) runs once, the first time.
|
|
5
|
+
*/
|
|
6
|
+
export class CronError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const NAMES = {
|
|
9
|
+
month: { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 },
|
|
10
|
+
dow: { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 },
|
|
11
|
+
};
|
|
12
|
+
const MACROS = { '@hourly': '0 * * * *', '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@weekly': '0 0 * * 0', '@monthly': '0 0 1 * *' };
|
|
13
|
+
function field(src, min, max, names) {
|
|
14
|
+
const num = (s) => {
|
|
15
|
+
const v = names?.[s.toUpperCase()] ?? (/^\d+$/.test(s) ? Number(s) : NaN);
|
|
16
|
+
if (!Number.isInteger(v) || v < min || v > max)
|
|
17
|
+
throw new CronError(`"${s}" is out of range (${min}-${max})`);
|
|
18
|
+
return v;
|
|
19
|
+
};
|
|
20
|
+
const out = new Set();
|
|
21
|
+
let any = false;
|
|
22
|
+
for (const part of src.split(',')) {
|
|
23
|
+
const [range, stepText] = part.split('/');
|
|
24
|
+
const step = stepText === undefined ? 1 : Number(stepText);
|
|
25
|
+
if (!Number.isInteger(step) || step < 1)
|
|
26
|
+
throw new CronError(`Invalid step "${stepText}"`);
|
|
27
|
+
let from, to;
|
|
28
|
+
if (range === '*' || range === '?') {
|
|
29
|
+
from = min;
|
|
30
|
+
to = max;
|
|
31
|
+
if (stepText === undefined)
|
|
32
|
+
any = true;
|
|
33
|
+
}
|
|
34
|
+
else if (range.includes('-')) {
|
|
35
|
+
const [a, b] = range.split('-');
|
|
36
|
+
from = num(a);
|
|
37
|
+
to = num(b);
|
|
38
|
+
if (from > to)
|
|
39
|
+
throw new CronError(`Range ${range} goes backwards`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
from = num(range);
|
|
43
|
+
to = stepText === undefined ? from : max;
|
|
44
|
+
}
|
|
45
|
+
for (let v = from; v <= to; v += step)
|
|
46
|
+
out.add(v);
|
|
47
|
+
}
|
|
48
|
+
return { values: [...out].sort((a, b) => a - b), any };
|
|
49
|
+
}
|
|
50
|
+
export function parseCron(expression) {
|
|
51
|
+
const text = MACROS[expression.trim().toLowerCase()] ?? expression.trim();
|
|
52
|
+
const parts = text.split(/\s+/);
|
|
53
|
+
if (parts.length !== 5)
|
|
54
|
+
throw new CronError('A cron expression has 5 fields: minute hour day-of-month month day-of-week (for example "0 9 * * MON-FRI")');
|
|
55
|
+
const minute = field(parts[0], 0, 59), hour = field(parts[1], 0, 23), dom = field(parts[2], 1, 31), month = field(parts[3], 1, 12, NAMES.month);
|
|
56
|
+
const dowRaw = field(parts[4].replace(/\b7\b/g, '0'), 0, 6, NAMES.dow);
|
|
57
|
+
return { minute: minute.values, hour: hour.values, dom: dom.values, month: month.values, dow: dowRaw.values, domAny: dom.any, dowAny: dowRaw.any };
|
|
58
|
+
}
|
|
59
|
+
// ---- time zones ----
|
|
60
|
+
const formats = new Map();
|
|
61
|
+
function format(tz) {
|
|
62
|
+
let f = formats.get(tz);
|
|
63
|
+
if (!f) {
|
|
64
|
+
f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
|
|
65
|
+
formats.set(tz, f);
|
|
66
|
+
}
|
|
67
|
+
return f;
|
|
68
|
+
}
|
|
69
|
+
export function validTimezone(tz) { try {
|
|
70
|
+
format(tz);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
} }
|
|
76
|
+
export const serverTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
77
|
+
function local(ts, tz) {
|
|
78
|
+
const p = {};
|
|
79
|
+
for (const x of format(tz).formatToParts(new Date(ts)))
|
|
80
|
+
if (x.type !== 'literal')
|
|
81
|
+
p[x.type] = Number(x.value);
|
|
82
|
+
return { y: p.year, m: p.month, d: p.day, h: p.hour % 24, mi: p.minute, s: p.second };
|
|
83
|
+
}
|
|
84
|
+
const offsetMs = (ts, tz) => { const l = local(ts, tz); return Date.UTC(l.y, l.m - 1, l.d, l.h, l.mi, l.s) - Math.floor(ts / 1000) * 1000; };
|
|
85
|
+
/** The instant at which the wall clock of `tz` reads y-m-d h:mi, or null when that time does not exist there (spring-forward gap). */
|
|
86
|
+
function zonedToUtc(y, m, d, h, mi, tz) {
|
|
87
|
+
const wall = Date.UTC(y, m - 1, d, h, mi);
|
|
88
|
+
let utc = wall - offsetMs(wall, tz);
|
|
89
|
+
utc = wall - offsetMs(utc, tz); // second pass: the offset may differ at the new instant
|
|
90
|
+
const l = local(utc, tz);
|
|
91
|
+
return l.y === y && l.m === m && l.d === d && l.h === h && l.mi === mi ? utc : null;
|
|
92
|
+
}
|
|
93
|
+
/** First run strictly after `after` (ms), or null if there is none in the next 8 years. */
|
|
94
|
+
export function nextCron(expression, tz, after) {
|
|
95
|
+
const spec = typeof expression === 'string' ? parseCron(expression) : expression;
|
|
96
|
+
const start = local(after, tz);
|
|
97
|
+
for (let k = 0; k < 366 * 8; k++) {
|
|
98
|
+
const day = new Date(Date.UTC(start.y, start.m - 1, start.d + k));
|
|
99
|
+
const y = day.getUTCFullYear(), m = day.getUTCMonth() + 1, d = day.getUTCDate(), wd = day.getUTCDay();
|
|
100
|
+
if (!spec.month.includes(m))
|
|
101
|
+
continue;
|
|
102
|
+
const domOk = spec.dom.includes(d), dowOk = spec.dow.includes(wd);
|
|
103
|
+
const dayOk = spec.domAny || spec.dowAny ? domOk && dowOk : domOk || dowOk; // both restricted: either matches (classic cron)
|
|
104
|
+
if (!dayOk)
|
|
105
|
+
continue;
|
|
106
|
+
for (const h of spec.hour)
|
|
107
|
+
for (const mi of spec.minute) {
|
|
108
|
+
const t = zonedToUtc(y, m, d, h, mi, tz);
|
|
109
|
+
if (t !== null && t > after)
|
|
110
|
+
return t;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** The next `count` runs. */
|
|
116
|
+
export function nextRuns(expression, tz, after, count) {
|
|
117
|
+
const spec = parseCron(expression), out = [];
|
|
118
|
+
let t = after;
|
|
119
|
+
while (out.length < count) {
|
|
120
|
+
const n = nextCron(spec, tz, t);
|
|
121
|
+
if (n === null)
|
|
122
|
+
break;
|
|
123
|
+
out.push(n);
|
|
124
|
+
t = n;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** Smallest gap, in minutes, between the next runs: protects against "* * * * *" and the like. */
|
|
129
|
+
export function minGapMinutes(expression, tz, after) {
|
|
130
|
+
const runs = nextRuns(expression, tz, after, 12);
|
|
131
|
+
if (runs.length < 2)
|
|
132
|
+
return Infinity;
|
|
133
|
+
let min = Infinity;
|
|
134
|
+
for (let i = 1; i < runs.length; i++)
|
|
135
|
+
min = Math.min(min, (runs[i] - runs[i - 1]) / 60000);
|
|
136
|
+
return min;
|
|
137
|
+
}
|
|
@@ -23,10 +23,23 @@ export function claudeFile(cwd, sessionId) {
|
|
|
23
23
|
catch { /* no Claude data yet */ }
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
|
+
// Parsing a long transcript (tens of MB) takes about a second and the UI asks for it often: keep the last results while the file is unchanged.
|
|
27
|
+
const cache = new Map();
|
|
26
28
|
export function readClaude(cwd, sessionId) {
|
|
27
29
|
const file = claudeFile(cwd, sessionId);
|
|
28
30
|
if (!file)
|
|
29
31
|
return [];
|
|
32
|
+
const st = statSync(file), sig = `${st.mtimeMs}:${st.size}`, hit = cache.get(file);
|
|
33
|
+
if (hit && hit.sig === sig)
|
|
34
|
+
return hit.msgs;
|
|
35
|
+
const msgs = parseClaude(file);
|
|
36
|
+
cache.delete(file);
|
|
37
|
+
cache.set(file, { sig, msgs });
|
|
38
|
+
if (cache.size > 4)
|
|
39
|
+
cache.delete(cache.keys().next().value);
|
|
40
|
+
return msgs;
|
|
41
|
+
}
|
|
42
|
+
function parseClaude(file) {
|
|
30
43
|
const out = [];
|
|
31
44
|
const byMsgId = new Map();
|
|
32
45
|
const toolBlocks = new Map();
|
package/server/dist/index.js
CHANGED
|
@@ -8,8 +8,16 @@ import { seedIfEmpty } from './seed.js';
|
|
|
8
8
|
import { API_PORT } from './mcp-config.js';
|
|
9
9
|
import './connections/index.js';
|
|
10
10
|
import { startAll } from './connections/manager.js';
|
|
11
|
+
import { migrateChannelSkill } from './connections/channel-skill.js';
|
|
12
|
+
import { armWakeups } from './wake.js';
|
|
13
|
+
import { armSchedules } from './schedules.js';
|
|
14
|
+
import { armWatches } from './watch.js';
|
|
11
15
|
skills.seedDefaults(DEFAULT_SKILLS);
|
|
12
16
|
seedIfEmpty();
|
|
17
|
+
migrateChannelSkill();
|
|
18
|
+
armWakeups();
|
|
19
|
+
armSchedules();
|
|
20
|
+
armWatches();
|
|
13
21
|
const server = createServer((req, res) => { void handle(req, res); });
|
|
14
22
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
15
23
|
wss.on('connection', (ws) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { agents, colonies, skills } from './db.js';
|
|
2
2
|
import { connections } from './connections/store.js';
|
|
3
3
|
import { platformName } from './connections/prompt.js';
|
|
4
|
-
import { NOTEBOOK_SKILL_ID } from './skills/defaults.js';
|
|
4
|
+
import { NOTEBOOK_SKILL_ID, WAKEUPS_SKILL_ID } from './skills/defaults.js';
|
|
5
5
|
import { NOTEBOOK_MAX, notebooks } from './skills/notebook.js';
|
|
6
6
|
/**
|
|
7
7
|
* What an agent is told and which hive tools it gets: its identity, prompt, skills, team, channels and notebook, and the
|
|
@@ -25,6 +25,8 @@ export function mcpCaps(a) {
|
|
|
25
25
|
caps.push('channel');
|
|
26
26
|
if (a.skill_ids.includes(NOTEBOOK_SKILL_ID))
|
|
27
27
|
caps.push('memory');
|
|
28
|
+
if (a.skill_ids.includes(WAKEUPS_SKILL_ID))
|
|
29
|
+
caps.push('wake');
|
|
28
30
|
if (lazySkills(a).length > 0)
|
|
29
31
|
caps.push('skills');
|
|
30
32
|
return caps;
|
|
@@ -46,6 +48,10 @@ export function composeInstructions(a, delegated = false) {
|
|
|
46
48
|
if (delegated)
|
|
47
49
|
id.push('This request was delegated to you by an orchestrator. Do the task fully and finish with a short, self-contained report: what you did, what you found, what is left.');
|
|
48
50
|
parts.push(id.join('\n'));
|
|
51
|
+
// OpenCode 2.x puts MCP tools behind its `execute` tool (JavaScript). Models that guess here waste several turns on `search`, shell placeholders and retries.
|
|
52
|
+
if (a.provider === 'opencode' && mcpCaps(a).length > 0)
|
|
53
|
+
parts.push(`## Calling the hive tools
|
|
54
|
+
The hive tools (\`tools.hive.*\`) are called from the \`execute\` tool, with \`await\`, using exactly the names given in these instructions. Do not \`search\` for them and do not probe with \`shell\` first: call them directly, e.g. \`execute\` with \`return await tools.hive.channel_reply({ text: "..." });\`. At the start of a turn the hive tools are still connecting, so your very first call can fail with \`Unknown tool 'hive.…'. Use search to find available tools.\` That is expected: ignore the hint, do not \`search\` and do not change tools; make the same call again right away with the same arguments and it will work. Use \`shell\` only for real shell work (commands, files), never to run JavaScript or as a placeholder. For a simple question, answer it in a single call.`);
|
|
49
55
|
if (a.system_prompt.trim())
|
|
50
56
|
parts.push(a.system_prompt.trim());
|
|
51
57
|
const lazy = lazySkills(a);
|
|
@@ -73,16 +79,16 @@ ${lazy.map((s) => `- **${s.name}**${s.description ? `: ${s.description}` : ''}`)
|
|
|
73
79
|
const links = connections.forAgent(a.id);
|
|
74
80
|
if (links.length) {
|
|
75
81
|
const names = [...new Set(links.map((c) => platformName(c.kind)))].join(' / ');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const aliases = [...new Set(links.flatMap((c) => (Array.isArray(c.config.aliases) ? c.config.aliases : []).map((x) => String(x).trim()).filter(Boolean)))];
|
|
83
|
+
if (aliases.length)
|
|
84
|
+
parts.push(`## Your names in chats\nBesides "${a.name}", people also call you ${aliases.map((x) => `"${x}"`).join(', ')}. These are your aliases: a message that uses one of them is aimed at you. If someone asks what your aliases or nicknames are, list them.`);
|
|
85
|
+
parts.push(`## Messages from ${names}
|
|
86
|
+
People can write to you from ${names}. Those messages start with a header line \`[hive:channel] …\` that says the platform, place, thread and sender. **Your normal text is not delivered to them**: to answer, call the \`channel_reply\` tool (${toolName(a.provider, 'channel_reply')}) with the text. Other tools: \`channel_send_file\` (${toolName(a.provider, 'channel_send_file')}) sends a file from your working folder; \`channel_mute\` (${toolName(a.provider, 'channel_mute')}) keeps you quiet in a thread. Follow the "Chat channels" skill for how to behave in chats.
|
|
87
|
+
Whatever your skills say: when you answer a \`[hive:channel]\` message, never write a secret (passwords, tokens, keys, \`.env\` contents) in the reply or in a file, to anyone, not even an admin. This does not apply to the hive-am web chat.`);
|
|
88
|
+
}
|
|
89
|
+
if (a.skill_ids.includes(WAKEUPS_SKILL_ID)) {
|
|
90
|
+
parts.push(`## Wake-ups
|
|
91
|
+
You cannot write on your own between messages. To tell the person something later, call \`wake_me\` (${toolName(a.provider, 'wake_me')}) with \`minutes\` and a \`note\`; you are woken then, in the same place, and answer. When you start a long command in the background and want to know the moment it ends, use \`wake_when_done\` (${toolName(a.provider, 'wake_when_done')}) with its pid. For something that repeats (every weekday at 9, every 30 minutes…) use \`schedule_create\` (${toolName(a.provider, 'schedule_create')}); \`schedule_list\` and \`schedule_cancel\` (${toolName(a.provider, 'schedule_list')}, ${toolName(a.provider, 'schedule_cancel')}) show or stop them. Follow the "Wake-ups" skill, and never promise a later notice without calling it.`);
|
|
86
92
|
}
|
|
87
93
|
if (a.skill_ids.includes(NOTEBOOK_SKILL_ID)) {
|
|
88
94
|
const nb = { read: toolName(a.provider, 'notebook_read'), add: toolName(a.provider, 'notebook_add'), rewrite: toolName(a.provider, 'notebook_rewrite') };
|
|
@@ -18,6 +18,8 @@ export async function* runClaude(o) {
|
|
|
18
18
|
args.push('--resume', agent.session_id);
|
|
19
19
|
if (agent.model)
|
|
20
20
|
args.push('--model', agent.model);
|
|
21
|
+
if (o.effort)
|
|
22
|
+
args.push('--effort', o.effort);
|
|
21
23
|
// Files people sent through a connection live outside the working folder; let the agent read them.
|
|
22
24
|
if (existsSync(inboxDir(agent.id)))
|
|
23
25
|
args.push('--add-dir', inboxDir(agent.id));
|
|
@@ -34,6 +34,8 @@ export async function* runKiro(o) {
|
|
|
34
34
|
args.push('--resume-id', agent.session_id);
|
|
35
35
|
if (agent.model)
|
|
36
36
|
args.push('--model', agent.model);
|
|
37
|
+
if (o.effort)
|
|
38
|
+
args.push('--effort', o.effort);
|
|
37
39
|
const prompt = withInstructions(o.prompt, o.instructions, !!agent.session_id, o.refreshInstructions);
|
|
38
40
|
args.push(prompt);
|
|
39
41
|
let sawSession = false;
|