openvisio-agent 0.19.11 → 0.19.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.11",
3
+ "version": "0.19.12",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,9 @@ const assertions = [
63
63
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
64
64
  ['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("reason: 'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
65
65
  ['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
66
- ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("!ownedActivity || memory.has(activitySourceKey, 'received')")],
67
- ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
66
+ ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("memory.has(activitySourceKey, 'received')")],
67
+ ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
68
+ ['agent-authored roots establish ownership and legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('await resolveOwnThread(cid, parent)') && watcher.includes('_threadRootContent: rootContent')],
68
69
  ['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
69
70
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
70
71
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
package/src/events.mjs CHANGED
@@ -400,7 +400,7 @@ export function renderedAgentMessages(value, identity = {}) {
400
400
  }
401
401
  }
402
402
  }
403
- for (const key of ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message']) {
403
+ for (const key of ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message', 'root']) {
404
404
  if (row[key] && typeof row[key] === 'object') visit(row[key], depth + 1)
405
405
  }
406
406
  }
@@ -487,12 +487,13 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
487
487
  // Require an actionable repository verb and a concrete code/repository object.
488
488
  // Broad nouns such as "feature", "API", "file", or "documentation" on their
489
489
  // own describe plenty of product conversations and must not start a coding lane.
490
- export function conversationNeedsCode(value) {
490
+ export function conversationNeedsCode(value, { rootContent = '' } = {}) {
491
491
  const text = String(value || '')
492
492
  if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
493
- const action = /\b(?:implement|fix|debug|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
493
+ const action = /\b(?:implement|fix|debug|repair|rebuild|rerun|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
494
494
  const target = /\b(?:code|codebase|repository|repo|github|branch|commit|pull\s+request|pr|endpoint|route|component|page|screen|ui|function|class|database|migration|schema|package|typescript|javascript|python|swift|rust|golang|css|html|source\s+file|tests?)\b/i.test(text)
495
- return action && target
495
+ const contextualRepair = /\b(?:fix|debug|repair|rebuild|rerun|run\s+(?:the\s+)?(?:build|tests?))\b/i.test(text) && /\b(?:github\.com\/\S+\/pull\/\d+|pull\s+request|repository|repo|branch|commit)\b/i.test(rootContent)
496
+ return (action && target) || contextualRepair
496
497
  }
497
498
 
498
499
  export function conversationAsksPendingTickets(value) {
package/src/memory.mjs CHANGED
@@ -155,5 +155,5 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
155
155
  }
156
156
 
157
157
  const settled = async () => { await Promise.all(pending.values()); await memory.settled() }
158
- return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, persist: ledger.persist, settled, provider: 'mastra-libsql' }
158
+ return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, get: ledger.get, persist: ledger.persist, settled, provider: 'mastra-libsql' }
159
159
  }
@@ -0,0 +1,27 @@
1
+ import { renderedAgentMessages } from './events.mjs'
2
+
3
+ // An agent's own root post establishes a conversation, even if nobody tagged
4
+ // the agent before replying. Verify the exact root, never mere participation.
5
+ export function ownThreadRoot(thread, rootId, identity) {
6
+ return renderedAgentMessages(thread, identity).find((row) => String(row.id) === String(rootId)) || null
7
+ }
8
+
9
+ export function createThreadContextResolver({ read, save, cancelled, load, identity }) {
10
+ const pending = new Map()
11
+ return async (channelId, rootId) => {
12
+ if (channelId == null || rootId == null || cancelled(channelId, rootId)) return ''
13
+ const cached = read(channelId, rootId)
14
+ if (cached) return cached
15
+ const key = `${channelId}:${rootId}`
16
+ if (!pending.has(key)) {
17
+ const request = Promise.resolve().then(() => load(channelId, rootId)).then((thread) => {
18
+ const root = ownThreadRoot(thread, rootId, identity())
19
+ if (!root || cancelled(channelId, rootId)) return ''
20
+ save(channelId, rootId, root.content)
21
+ return root.content
22
+ }).finally(() => pending.delete(key))
23
+ pending.set(key, request)
24
+ }
25
+ return pending.get(key)
26
+ }
27
+ }
package/src/watch.mjs CHANGED
@@ -20,6 +20,7 @@ import { mapConcurrent } from './concurrency.mjs'
20
20
  import { releaseAuthorizedPause } from './authorization-resume.mjs'
21
21
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
22
22
  import { createTaskTypeLookup } from './task-types.mjs'
23
+ import { createThreadContextResolver } from './thread-context.mjs'
23
24
  import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
24
25
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
25
26
  import { createMastraAcpRunner } from './mastra-harness.mjs'
@@ -876,6 +877,25 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
876
877
  const controlKey = threadControlKey(channelId, parentId)
877
878
  if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
878
879
  }
880
+ const saveOwnThread = (channelId, rootId, content) => {
881
+ memory.remember({ key: `own-root:${channelId}:${rootId}`, kind: 'thread', state: 'authored', summary: content, refs: { channelId: Number(channelId), threadId: rootId }, meta: { rootContent: content } })
882
+ if (!memory.has(threadControlKey(channelId, rootId), 'cancelled')) activateThread(channelId, rootId, content)
883
+ }
884
+ const resolveOwnThread = createThreadContextResolver({
885
+ read: (cid, root) => {
886
+ const cached = memory.get(`own-root:${cid}:${root}`)?.meta?.rootContent
887
+ if (cached) return cached
888
+ // Previous releases recorded the posted message id but not ownership.
889
+ // This also recovers roots when the thread API returns only replies.
890
+ const previous = memory.recall({ channelId: cid }, 1000).find((node) => node.kind === 'delivery' && node.state === 'rendered' && node.refs?.threadId == null && String(node.meta?.messageId) === String(root))
891
+ if (previous?.summary) saveOwnThread(cid, root, previous.summary)
892
+ return previous?.summary || ''
893
+ },
894
+ save: saveOwnThread,
895
+ cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
896
+ identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
897
+ load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
898
+ })
879
899
  const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
880
900
  const deliveryKey = String(key || '')
881
901
  if (parentId == null) {
@@ -923,6 +943,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
923
943
  ...(parentId != null ? { parent_id: Number(parentId) } : {}),
924
944
  content: message,
925
945
  }))
946
+ const postedId = result.id ?? result.message?.id ?? result.message_id
947
+ if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
926
948
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
927
949
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
928
950
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
@@ -1193,7 +1215,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1193
1215
  // old tagged thread root can otherwise make another agent's new reply
1194
1216
  // look addressed to this watcher. Explicit mentions and human follow-ups
1195
1217
  // in a persistently owned thread both enter the guarded per-message path.
1196
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1218
+ const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
1197
1219
  if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1198
1220
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1199
1221
  // The same logical mention may already have arrived over WebSocket.
@@ -1203,7 +1225,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1203
1225
  if (markMentionHandled(activityMessage, activityChannelId)) {
1204
1226
  const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1205
1227
  const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1206
- if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
1228
+ if (memory.has(activitySourceKey, 'received')) continue
1207
1229
  }
1208
1230
  mentionActivity.push({
1209
1231
  projectId: project.id,
@@ -1519,7 +1541,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1519
1541
  }
1520
1542
  }
1521
1543
 
1544
+ const mentionIntake = new Map()
1522
1545
  function onEvent(k, d) {
1546
+ if (k !== 'agent:mention') return routeEvent(k, d)
1547
+ const cid = d?.channel_id ?? d?.channelId
1548
+ const message = d?.message || {}
1549
+ const parent = message.parent_id ?? message.parentId
1550
+ const key = `${cid}:${parent ?? message.id ?? message.message_id}`
1551
+ // Keep redirects ordered while a legacy root is being verified. Other
1552
+ // conversations can resolve and run independently.
1553
+ const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
1554
+ const preliminary = classifyConversationTarget(message, [...selfAliases])
1555
+ const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
1556
+ const rootContent = parent == null || irrelevant || preliminary.action === 'stand_down' ? '' : await resolveOwnThread(cid, parent)
1557
+ routeEvent(k, { ...d, _threadRootContent: rootContent })
1558
+ }).catch((error) => log('mention thread verification failed: ' + (error?.message || error)))
1559
+ .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
1560
+ mentionIntake.set(key, next)
1561
+ return next
1562
+ }
1563
+
1564
+ function routeEvent(k, d) {
1523
1565
  const raw = d && typeof d === 'object' ? d : {}
1524
1566
  if (k === 'task:assigned' || k === 'task:updated') {
1525
1567
  void handleTaskSignal(k, raw)
@@ -1629,13 +1671,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1629
1671
  // Light up the live status the instant we pick this up; drain owns the
1630
1672
  // subsequent working/typing heartbeat for its lane.
1631
1673
  if (cid != null) sendStatus(cid, 'thinking')
1632
- const codingMention = canCode && conversationNeedsCode(text)
1674
+ const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
1633
1675
  const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1634
- const ctx = cid != null
1676
+ let ctx = cid != null
1635
1677
  ? replyDelivery?.watcherOwned
1636
1678
  ? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1637
1679
  : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : 'Send EXACTLY ONE reply with post_message'}: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
1638
1680
  : undefined
1681
+ if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
1639
1682
  if (codingMention) {
1640
1683
  // Activity indicators make accepted work visible. Do not add a canned
1641
1684
  // pickup message; the first channel message is the verified result or a