openvisio-agent 0.19.9 → 0.19.11

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/src/memory.mjs CHANGED
@@ -44,6 +44,10 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
44
44
  meta: { ...(previous?.meta || {}), ...meta },
45
45
  updatedAt: stamp,
46
46
  }
47
+ // Reconciliation frequently sees the same revision. Avoid rewriting both
48
+ // stores and appending identical context on every sweep.
49
+ if (previous && previous.kind === node.kind && previous.state === node.state && previous.summary === node.summary &&
50
+ JSON.stringify(previous.refs) === JSON.stringify(node.refs) && JSON.stringify(previous.meta) === JSON.stringify(node.meta)) return previous
47
51
  nodes.set(id, node); trim(); persist()
48
52
  return node
49
53
  }
@@ -80,7 +84,7 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
80
84
  return !!node && (state == null || node.state === state)
81
85
  }
82
86
 
83
- return { remember, connect, recall, context, has, persist }
87
+ return { remember, connect, recall, context, has, persist, get: (key) => nodes.get(String(key)) }
84
88
  }
85
89
 
86
90
  const memoryThreadId = (refs = {}) => {
@@ -103,14 +107,14 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
103
107
  const storage = new LibSQLStore({ id: `openvisio-memory-${resourceId}`, url: `file:${databasePath}` })
104
108
  const memory = new Memory({ storage, options: { lastMessages: 12, semanticRecall: false, workingMemory: { enabled: false } } })
105
109
  const threads = new Map()
106
- let pending = Promise.resolve()
110
+ const pending = new Map()
107
111
 
108
112
  const ensureThread = (threadId) => {
109
113
  if (!threads.has(threadId)) {
110
114
  threads.set(threadId, (async () => {
111
115
  const existing = await memory.getThreadById({ threadId, resourceId })
112
116
  return existing || memory.createThread({ threadId, resourceId, title: threadId })
113
- })())
117
+ })().catch((error) => { threads.delete(threadId); throw error }))
114
118
  }
115
119
  return threads.get(threadId)
116
120
  }
@@ -118,7 +122,7 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
118
122
  const queueMessage = (entry) => {
119
123
  const threadId = memoryThreadId(entry?.refs)
120
124
  if (!threadId) return
121
- pending = pending.then(async () => {
125
+ const write = (pending.get(threadId) || Promise.resolve()).then(async () => {
122
126
  await ensureThread(threadId)
123
127
  const text = `${entry.kind || 'event'} ${entry.state || 'observed'}: ${entry.summary || entry.key}`
124
128
  await memory.saveMessages({ messages: [{
@@ -126,18 +130,22 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
126
130
  content: { format: 2, parts: [{ type: 'text', text }], metadata: { openvisioKey: entry.key } },
127
131
  }] })
128
132
  }).catch(() => { /* memory is best-effort; live backend checks stay authoritative */ })
133
+ pending.set(threadId, write)
134
+ void write.finally(() => { if (pending.get(threadId) === write) pending.delete(threadId) })
129
135
  }
130
136
 
131
137
  const remember = (entry) => {
138
+ const previous = ledger.get(entry.key)
132
139
  const node = ledger.remember(entry)
133
- if (node) queueMessage(node)
140
+ if (node && node !== previous) queueMessage(node)
134
141
  return node
135
142
  }
136
143
 
137
144
  const context = async (refs = {}, limit = 8) => {
138
145
  const threadId = memoryThreadId(refs)
139
146
  if (!threadId) return ''
140
- await pending
147
+ // Writes for unrelated tickets must never delay a conversation reply.
148
+ await pending.get(threadId)
141
149
  try {
142
150
  const recalled = await memory.recall({ threadId, resourceId, perPage: limit })
143
151
  const items = recalled.messages.map(messageText).filter(Boolean).slice(-limit)
@@ -146,6 +154,6 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
146
154
  return ledger.context(refs, limit)
147
155
  }
148
156
 
149
- const settled = async () => { await pending; await memory.settled() }
157
+ const settled = async () => { await Promise.all(pending.values()); await memory.settled() }
150
158
  return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, persist: ledger.persist, settled, provider: 'mastra-libsql' }
151
159
  }
@@ -35,6 +35,7 @@ export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
35
35
  skill: 'deny',
36
36
  question: 'deny',
37
37
  'openvisio-team_*': 'allow',
38
+ 'openvisio-team-watcher_*': 'allow',
38
39
  }
39
40
  return {
40
41
  $schema: 'https://opencode.ai/config.json',
package/src/pr-push.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
- import { dirname, join } from 'node:path'
4
+ import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
5
+ import { writeJson } from './lib.mjs'
5
6
 
6
- const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
+ export const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
8
  const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
8
9
 
9
10
  const gitText = (cwd, args, spawn = spawnSync) => {
@@ -32,7 +33,7 @@ export function isSafeAgentBranch(value) {
32
33
 
33
34
  export function codexPrPushRule() {
34
35
  return `# Generated by: openvisio-agent authorize-pr-push
35
- # The helper enforces an exact pre-authorized repository + origin and only
36
+ # The helper enforces the configured workspace or exact repository + origin and only
36
37
  # pushes the current agent/* branch. It accepts no force, remote, or ref args.
37
38
  prefix_rule(
38
39
  pattern = ["openvisio-agent", "push-pr-branch"],
@@ -50,6 +51,41 @@ function readAuthorizations(path) {
50
51
  } catch { return [] }
51
52
  }
52
53
 
54
+ function readPolicy(path) {
55
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return {} }
56
+ }
57
+
58
+ const within = (root, path) => {
59
+ if (!root || !path) return false
60
+ const rel = relative(root, path)
61
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')))
62
+ }
63
+
64
+ function policyAllows(identity, policy) {
65
+ const roots = [identity.root, identity.commonDir].filter(Boolean)
66
+ if ((policy.revoked || []).some((entry) => roots.includes(entry))) return false
67
+ return (policy.authorizations || []).some((entry) => entry.remote === identity.remote &&
68
+ (entry.root === identity.root || (entry.commonDir && entry.commonDir === identity.commonDir))) ||
69
+ (policy.workspaces || []).some((entry) => roots.some((root) => within(entry.root, root)))
70
+ }
71
+
72
+ // Connecting a coding workspace grants routine PR delivery once, including
73
+ // future clones and worktrees. The helper still controls branch and destination.
74
+ export function configurePrPublishing({ workspace, enabled = true, authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH } = {}) {
75
+ if (!workspace) return null
76
+ mkdirSync(workspace, { recursive: true })
77
+ const root = realpathSync(workspace)
78
+ if (dirname(root) === root || root === homedir()) throw new Error('Choose a dedicated coding workspace, not the home directory or filesystem root.')
79
+ const policy = readPolicy(authPath)
80
+ const workspaces = (policy.workspaces || []).filter((entry) => entry.root !== root)
81
+ if (enabled) workspaces.push({ root })
82
+ if (JSON.stringify(workspaces) !== JSON.stringify(policy.workspaces || [])) {
83
+ writeJson(authPath, { ...policy, version: 2, workspaces }, true)
84
+ }
85
+ if (enabled) writePrivate(rulePath, codexPrPushRule())
86
+ return { root, enabled }
87
+ }
88
+
53
89
  function writePrivate(path, value) {
54
90
  mkdirSync(dirname(path), { recursive: true })
55
91
  writeFileSync(path, value, { mode: 0o600 })
@@ -60,14 +96,15 @@ export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
60
96
  const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
61
97
  const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
62
98
  const branch = gitText(root, ['branch', '--show-current'], spawn)
63
- return { root, remote, branch }
99
+ const commonDir = realpathSync(resolve(root, gitText(root, ['rev-parse', '--git-common-dir'], spawn)))
100
+ return { root, commonDir, remote, branch }
64
101
  }
65
102
 
66
- export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
103
+ export function validatePrPushAuthorization({ root, commonDir, remote, branch, authorizations, workspaces = [], revoked = [] }) {
67
104
  if (!isSafeAgentBranch(branch)) {
68
105
  throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
69
106
  }
70
- const allowed = (Array.isArray(authorizations) ? authorizations : []).some((entry) => entry?.root === root && entry?.remote === remote)
107
+ const allowed = policyAllows({ root, commonDir, remote }, { authorizations, workspaces, revoked })
71
108
  if (!allowed) {
72
109
  throw new Error(`OPENVISIO_PR_PUSH_AUTH_REQUIRED: This repository and its exact origin are not authorized. From ${root}, run: openvisio-agent authorize-pr-push`)
73
110
  }
@@ -76,37 +113,39 @@ export function validatePrPushAuthorization({ root, remote, branch, authorizatio
76
113
 
77
114
  export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
78
115
  try {
79
- const { root, remote } = repositoryPushIdentity(cwd, spawn)
80
- return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
116
+ return policyAllows(repositoryPushIdentity(cwd, spawn), readPolicy(authPath))
81
117
  } catch { return false }
82
118
  }
83
119
 
84
120
  export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH, spawn = spawnSync, now = () => new Date().toISOString() } = {}) {
85
- const { root, remote } = repositoryPushIdentity(cwd, spawn)
121
+ const { root, commonDir, remote } = repositoryPushIdentity(cwd, spawn)
122
+ const policy = readPolicy(authPath)
86
123
  const authorizations = readAuthorizations(authPath).filter((entry) => entry?.root !== root)
87
- authorizations.push({ root, remote, authorizedAt: now() })
88
- writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
124
+ authorizations.push({ root, commonDir, remote, authorizedAt: now() })
125
+ writeJson(authPath, { ...policy, version: 2, authorizations, revoked: (policy.revoked || []).filter((path) => path !== root && path !== commonDir) }, true)
89
126
  writePrivate(rulePath, codexPrPushRule())
90
127
  return { root, remote, authPath, rulePath }
91
128
  }
92
129
 
93
130
  export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
94
- const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
131
+ const { root, commonDir } = repositoryPushIdentity(cwd, spawn)
132
+ const policy = readPolicy(authPath)
95
133
  const before = readAuthorizations(authPath)
96
134
  const authorizations = before.filter((entry) => entry?.root !== root)
97
- if (existsSync(authPath)) writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
98
- return { root, revoked: authorizations.length !== before.length }
135
+ writeJson(authPath, { ...policy, version: 2, authorizations, revoked: [...new Set([...(policy.revoked || []), commonDir])] }, true)
136
+ return { root, revoked: true }
99
137
  }
100
138
 
101
139
  export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
102
140
  const identity = repositoryPushIdentity(cwd, spawn)
103
- validatePrPushAuthorization({ ...identity, authorizations: readAuthorizations(authPath) })
141
+ validatePrPushAuthorization({ ...identity, ...readPolicy(authPath) })
104
142
  const destination = `HEAD:refs/heads/${identity.branch}`
105
143
  // The helper runs outside Codex's workspace sandbox after the user authorizes
106
144
  // it. Disable repository hooks so an edited pre-push hook cannot widen this
107
145
  // one operation into arbitrary host execution.
108
146
  const nullHooks = process.platform === 'win32' ? 'NUL' : '/dev/null'
109
- const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', 'origin', destination], { cwd: identity.root, stdio: 'inherit' })
147
+ // Pin the verified URL: `git push origin` may fan out to multiple pushurls.
148
+ const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', identity.remote, destination], { cwd: identity.root, stdio: 'inherit' })
110
149
  if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
111
150
  return identity
112
151
  }
@@ -0,0 +1,38 @@
1
+ // Bounded LRU of warm sessions, pinned while in use. Never evict an active
2
+ // session, and never share history between tickets or conversation threads.
3
+ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now }) {
4
+ const entries = new Map()
5
+ function prune() {
6
+ for (const [key, entry] of entries) {
7
+ if (!entry.busy && (now() - entry.usedAt >= idleMs || entries.size > capacity)) {
8
+ entry.runner.close?.()
9
+ entries.delete(key)
10
+ }
11
+ }
12
+ }
13
+ const timer = setInterval(prune, Math.min(idleMs, 30_000))
14
+ timer.unref?.()
15
+ return {
16
+ acquire(key, options) {
17
+ prune()
18
+ let entry = entries.get(key)
19
+ if (entry?.busy) throw new Error('Session already has an active cycle')
20
+ if (!entry) entry = { runner: create(options), usedAt: now(), busy: false }
21
+ entries.delete(key)
22
+ entry.busy = true
23
+ entries.set(key, entry)
24
+ prune()
25
+ return entry.runner
26
+ },
27
+ release(key) {
28
+ const entry = entries.get(key)
29
+ if (entry) { entry.busy = false; entry.usedAt = now() }
30
+ prune()
31
+ },
32
+ close() {
33
+ clearInterval(timer)
34
+ for (const entry of entries.values()) entry.runner.close?.()
35
+ entries.clear()
36
+ },
37
+ }
38
+ }
@@ -0,0 +1,24 @@
1
+ export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
2
+ const cache = new Map(), pending = new Map()
3
+ return async (projectId) => {
4
+ const key = String(projectId)
5
+ const hit = cache.get(key)
6
+ if (hit && hit.expiresAt > now()) return hit.value
7
+ if (pending.has(key)) return pending.get(key)
8
+ const request = Promise.resolve().then(() => load(projectId)).then((data) => {
9
+ const types = data.types ?? data.task_types ?? data.taskTypes
10
+ if (!Array.isArray(types)) throw new Error('Cannot verify task columns')
11
+ const matching = (pattern) => new Set(types.filter((type) => pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
12
+ const value = {
13
+ done: matching(/\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i),
14
+ review: matching(/\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i),
15
+ }
16
+ cache.delete(key)
17
+ cache.set(key, { value, expiresAt: now() + ttlMs })
18
+ while (cache.size > 100) cache.delete(cache.keys().next().value)
19
+ return value
20
+ }).finally(() => pending.delete(key))
21
+ pending.set(key, request)
22
+ return request
23
+ }
24
+ }