openvisio-agent 0.3.3 → 0.3.5
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/package.json +1 -1
- package/src/watch.mjs +70 -15
- package/src/ws.mjs +6 -1
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -11,10 +11,15 @@ import { join, dirname } from 'node:path'
|
|
|
11
11
|
import { OV_DIR, readConfig, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
14
|
+
// Shared preamble — the #1 cause of slow replies was the model shelling around to
|
|
15
|
+
// "find credentials". The openvisio-team MCP tools are authenticated by the
|
|
16
|
+
// connection itself, so this hammers that shut.
|
|
17
|
+
const AUTH_NOTE = 'IMPORTANT: your openvisio-team (mcp__openvisio-team__*) tools are ALREADY authenticated through this connection. You do NOT need — and must NEVER look for — any API key, credential, identifier, token, or config file. NEVER run Bash/shell/grep/cat/find or read your memory to locate credentials or tools; just CALL the openvisio-team tools directly. If one returns unauthorized, say so briefly in one line — do not try to fix it by hunting for keys. Act immediately with the fewest tool calls.'
|
|
18
|
+
|
|
19
|
+
const CYCLE = 'Run one OpenVisio autonomy cycle. ' + AUTH_NOTE
|
|
20
|
+
const CYCLE_FAST = AUTH_NOTE + ' New chat activity in OpenVisio. If a specific mention is given above, post that reply FIRST with mcp__openvisio-team__post_message. Then call poll_inbox once and also handle any .followUps (thread replies you are part of, even without an @mention) — ignore .tasks/.claimable. Post AT MOST ONE reply per channel: if the same person sent several nudges, answer them together in ONE post. HONESTY: your only tools are the openvisio-team chat/ticket tools — you cannot write code or touch other tools; if asked for work you have no tool for, say so plainly in one short message (or offer to file a ticket). Never invent progress. Reply in 1-3 sentences, no summary. Then stop.'
|
|
21
|
+
const CODE_FULL = 'Run one OpenVisio autonomy cycle. ' + AUTH_NOTE + ' Call get_marching_orders and poll_inbox. You have file + Bash tools and a git repo at your working directory FOR CODE WORK ONLY. For assigned tickets or mentions asking for real code work: FIRST "git checkout -B agent/work", make the changes with Read/Edit/Write, run tests if present, then "git add -A && git commit -m ...". NEVER git push, merge, or touch main. Then comment_ticket with a short summary + the branch name, and post a brief channel reply. Do NOT use Bash for anything other than git/tests/build — never to find credentials. If you truly cannot (missing repo/specs), say so in one message — never fabricate.'
|
|
22
|
+
const CODE_FAST = AUTH_NOTE + ' New chat activity in OpenVisio. If a specific mention is given above, post that reply FIRST with mcp__openvisio-team__post_message — do this before any Bash. Then poll_inbox and handle .followUps (thread replies you are part of, even without an @mention), AT MOST ONE reply per channel. Only if a message asks for real CODE work in your git repo, do it on a branch (checkout -B agent/work, edit, commit locally — never push/merge) and reply with the branch name. Bash is for git/tests ONLY, never for credentials. 1-3 sentences, no summary. Then stop.'
|
|
18
23
|
|
|
19
24
|
const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
|
|
20
25
|
const DENY_TOOLS = ['Bash(git push:*)', 'Bash(git reset --hard:*)', 'Bash(git clean:*)', 'Bash(rm:*)', 'Bash(sudo:*)', 'Bash(chmod:*)', 'Bash(curl:*)', 'Bash(wget:*)', 'Bash(npm publish:*)', 'Bash(pnpm publish:*)', 'Bash(gh pr merge:*)', 'Bash(gh repo:*)']
|
|
@@ -154,39 +159,89 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
|
|
|
154
159
|
|
|
155
160
|
let busy = false
|
|
156
161
|
let queued = null // 'full' | 'fast' — a cycle requested while one was running
|
|
162
|
+
let handle = null
|
|
163
|
+
let lastActivityAt = Date.now()
|
|
164
|
+
// Context lines from the events themselves (the WS payload already carries the
|
|
165
|
+
// channel + message / task), so the agent acts on THEM directly instead of
|
|
166
|
+
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
167
|
+
// events and drained into the next cycle's prompt.
|
|
168
|
+
const pending = []
|
|
169
|
+
|
|
170
|
+
// Typing indicator — pulse `channel:typing` for the channel we're working on so
|
|
171
|
+
// the reply feels immediate while the cycle runs. Cleared when the queue drains.
|
|
172
|
+
let typingChan = null
|
|
173
|
+
let typingTimer = null
|
|
174
|
+
const pulseTyping = () => { if (typingChan != null && handle) handle.sendTyping(typingChan) }
|
|
175
|
+
const startTyping = (cid) => { if (cid == null) return; typingChan = cid; pulseTyping(); if (!typingTimer) typingTimer = setInterval(pulseTyping, 3000) }
|
|
176
|
+
const stopTyping = () => { typingChan = null; if (typingTimer) { clearInterval(typingTimer); typingTimer = null } }
|
|
157
177
|
|
|
158
|
-
async function drain(kind) {
|
|
178
|
+
async function drain(kind, context) {
|
|
179
|
+
if (context) pending.push(context)
|
|
159
180
|
if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
160
181
|
busy = true
|
|
161
|
-
|
|
182
|
+
const ctx = pending.splice(0) // take everything accumulated so far
|
|
183
|
+
const base = kind === 'full' ? fullPrompt : fastPrompt
|
|
184
|
+
const prompt = ctx.length ? ctx.join('\n') + '\n\n' + base : base
|
|
185
|
+
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
|
|
162
186
|
try {
|
|
163
|
-
await runCycle(
|
|
187
|
+
await runCycle(prompt)
|
|
164
188
|
} finally {
|
|
165
189
|
busy = false
|
|
166
|
-
if (queued) { const next = queued; queued = null; void drain(next) }
|
|
190
|
+
if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
|
|
191
|
+
else stopTyping()
|
|
167
192
|
}
|
|
168
193
|
}
|
|
169
194
|
|
|
195
|
+
// Distill an event's WS payload into a directive the model can act on without
|
|
196
|
+
// any extra lookup.
|
|
197
|
+
const senderName = (m) => {
|
|
198
|
+
const s = m && (m.sender || m.user || m.author)
|
|
199
|
+
if (!s) return ''
|
|
200
|
+
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
201
|
+
}
|
|
202
|
+
|
|
170
203
|
function onEvent(k, d) {
|
|
204
|
+
const raw = d && typeof d === 'object' ? d : {}
|
|
205
|
+
lastActivityAt = Date.now()
|
|
171
206
|
if (k === 'task:assigned') {
|
|
172
|
-
const t =
|
|
173
|
-
log('task:assigned ' + (t ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
|
|
174
|
-
|
|
207
|
+
const t = raw.task || {}
|
|
208
|
+
log('task:assigned ' + (t.id != null ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
|
|
209
|
+
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
210
|
+
void drain('full', t.id != null ? `You were ASSIGNED task #${t.id}: "${t.title || ''}"${desc}. Handle it, then comment_ticket with a short summary.` : undefined)
|
|
175
211
|
} else if (k === 'agent:mention') {
|
|
176
|
-
|
|
177
|
-
|
|
212
|
+
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
213
|
+
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
214
|
+
const text = String(msg.content || msg.body || msg.text || '').replace(/\s+/g, ' ').slice(0, 600)
|
|
215
|
+
const who = senderName(msg)
|
|
216
|
+
log('agent:mention in channel ' + (cid != null ? cid : '?'))
|
|
217
|
+
startTyping(cid) // show the agent as typing immediately, before the cycle finishes
|
|
218
|
+
const ctx = cid != null
|
|
219
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by ${who}` : ''}: "${text}". Your FIRST action must be mcp__openvisio-team__post_message with channel_id ${cid} and a 1-3 sentence reply — you already have the message here, so do NOT poll_inbox to find it and do NOT run any Bash/shell to look for credentials or tools (they are already authenticated). After posting, you MAY poll_inbox once for other .followUps.`
|
|
220
|
+
: undefined
|
|
221
|
+
void drain('fast', ctx)
|
|
178
222
|
} else {
|
|
179
223
|
log('event ' + k)
|
|
180
224
|
}
|
|
181
225
|
}
|
|
182
226
|
|
|
227
|
+
// Follow-up sweep: thread replies you are part of get NO @mention (no WS event
|
|
228
|
+
// fires), so poll the inbox on a slow cadence to catch .followUps — but ONLY
|
|
229
|
+
// within a short window after real activity, so an idle agent costs nothing.
|
|
230
|
+
const FOLLOWUP_MS = 60_000
|
|
231
|
+
const ACTIVE_WINDOW_MS = 5 * 60_000
|
|
232
|
+
const sweep = setInterval(() => {
|
|
233
|
+
if (busy) return
|
|
234
|
+
if (Date.now() - lastActivityAt > ACTIVE_WINDOW_MS) return // idle → don't spend a cycle
|
|
235
|
+
void drain('fast', 'Follow-up check: call poll_inbox and reply to any NEW .followUps (thread replies you are part of, even with no @mention) or unanswered .mentions. If there is nothing new, do nothing and stop — do NOT post.')
|
|
236
|
+
}, FOLLOWUP_MS)
|
|
237
|
+
|
|
183
238
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
184
|
-
|
|
239
|
+
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
185
240
|
|
|
186
241
|
return new Promise(() => {
|
|
187
242
|
// Run until killed. Tidy up the socket on termination so a restarting
|
|
188
243
|
// service doesn't leak a half-open connection.
|
|
189
|
-
const bye = () => { try { handle.close() } catch { /* noop */ } process.exit(0) }
|
|
244
|
+
const bye = () => { clearInterval(sweep); stopTyping(); try { handle && handle.close() } catch { /* noop */ } process.exit(0) }
|
|
190
245
|
process.on('SIGTERM', bye)
|
|
191
246
|
process.on('SIGINT', bye)
|
|
192
247
|
})
|
package/src/ws.mjs
CHANGED
|
@@ -95,7 +95,11 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
|
95
95
|
|
|
96
96
|
open()
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
const api = {
|
|
99
|
+
/** Fire-and-forget send (no-op if the socket isn't open). */
|
|
100
|
+
send(obj) { try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)) } catch { /* closing */ } },
|
|
101
|
+
/** Show the agent as typing in a channel (docs/WEBSOCKET.md `{type:'typing'}`). */
|
|
102
|
+
sendTyping(channelId) { api.send({ type: 'typing', channel_id: channelId }) },
|
|
99
103
|
close() {
|
|
100
104
|
closed = true
|
|
101
105
|
clearKeepalive()
|
|
@@ -103,4 +107,5 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
|
103
107
|
if (ws) { try { ws.close() } catch { /* already gone */ } ws = null }
|
|
104
108
|
},
|
|
105
109
|
}
|
|
110
|
+
return api
|
|
106
111
|
}
|