openvisio-agent 0.21.1 → 0.23.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 +19 -0
- package/README.md +25 -2
- package/USER_GUIDE.md +10 -2
- package/package.json +3 -2
- package/scenarios/runtime.scenarios.mjs +6 -11
- package/scenarios/workspace.scenarios.mjs +2 -1
- package/scripts/certify.mjs +11 -11
- package/scripts/smoke-context-search.mjs +33 -0
- package/src/codex-mcp-proxy.mjs +38 -2
- package/src/context-search.mjs +168 -0
- package/src/context-service.mjs +103 -0
- package/src/context-tools.mjs +44 -0
- package/src/embedding-worker.mjs +29 -0
- package/src/events.mjs +7 -1
- package/src/mastra-harness.mjs +23 -18
- package/src/memory.mjs +7 -6
- package/src/opencode-config.mjs +13 -16
- package/src/runtime-control.mjs +50 -0
- package/src/watch.mjs +173 -129
- package/studio/app.mjs +6 -5
- package/studio/guide.html +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.0] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Add local semantic and keyword search over recorded conversations with source references, bounded retention, edit replacement, and backend/agent isolation.
|
|
6
|
+
- Add live assignment lookup and intent-based discovery of currently advertised tools, including a task-offer cue in every agent cycle.
|
|
7
|
+
- Expose two on-demand Mastra skills for assignment handling and conversation recall through the native runtime MCP bridge.
|
|
8
|
+
- Run local embedding inference outside the agent loop, preserve keyword fallback during model download/failure, and keep live task state authoritative.
|
|
9
|
+
- Known dependency finding: FastEmbed's ONNX installer inherits the `adm-zip` audit findings documented in the README. Local and simulated runtime tests pass; live team/model behavior requires validation in a safe test organization.
|
|
10
|
+
|
|
11
|
+
## [0.22.0] — 2026-09-10
|
|
12
|
+
|
|
13
|
+
- Carry the agent’s configured role and voice through every session, with cached identity when the roster is unavailable.
|
|
14
|
+
- Let agents request their own coding workspace and carry forward findings without asking teammates to reassign work. Record the continuation in the source context graph and Studio.
|
|
15
|
+
- Bound graph recall to direct source neighbors so shared nodes cannot pull in unrelated conversations.
|
|
16
|
+
|
|
17
|
+
- Let the agent's native final turn end a cycle. Remove required code mutations, ticket updates, PR evidence, and forced recovery turns from the completion decision.
|
|
18
|
+
- Deliver and persist the agent's actual response, including research, questions, and blockers, without automatically completing the ticket or rerunning an unchanged assignment after restart.
|
|
19
|
+
- Expose advertised MCP capabilities independently of scheduling classification; allow native reading, search, browsing, planning, and context tools in reply sessions while keeping local writes scoped to coding workspaces.
|
|
20
|
+
- Keep Claude context warm like Codex and OpenCode, tolerate unavailable optional history, and treat runtime deadlines as inactivity watchdogs.
|
|
21
|
+
|
|
3
22
|
## [0.21.1] — 2026-09-10
|
|
4
23
|
|
|
5
24
|
- Recover identifier-owned assignments even when the agent is missing from `list_agents` or the roster request fails. Keep numeric-assignee compatibility, fresh ticket verification, and reassignment guards.
|
package/README.md
CHANGED
|
@@ -62,13 +62,28 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
62
62
|
|
|
63
63
|
Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
64
64
|
|
|
65
|
+
Backend watchers also provide four local context tools through the authenticated MCP bridge:
|
|
66
|
+
|
|
67
|
+
| Tool | Purpose |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `openvisio_list_my_tasks` | Read live pending assignments, including when someone says “I have a task for you.” Does not claim or start work. |
|
|
70
|
+
| `openvisio_find_tools` | Search currently advertised tools by intent and return their actual input schemas, plus available skill descriptions. |
|
|
71
|
+
| `openvisio_search_conversations` | Search recorded conversations by meaning and keywords, optionally narrowed to a channel or thread. |
|
|
72
|
+
| `openvisio_load_skill` | Load the `openvisio-work` or `conversation-recall` skill when useful, including after compaction. |
|
|
73
|
+
|
|
74
|
+
Conversation search combines Mastra's `LibSQLVector` with local BGE-small embeddings from `@mastra/fastembed` and keyword ranking. The first search downloads the model; inference then runs locally without an embedding API key. A worker handles inference independently of the agent loop. Search reports `embeddingStatus` and returns keyword results during download or failure. No conversation text is sent to an embedding provider.
|
|
75
|
+
|
|
76
|
+
The corpus contains accepted messages and messages obtained through successful `list_message_thread` or `post_message` calls. It does not automatically crawl the whole organization. Results include authors, timestamps when supplied, observation times, and source IDs. Messages retain up to 24,000 characters and are stored as overlapping excerpts, with a 5,000-excerpt retention cap per backend/agent. Observed edits replace previous content; observed deletions remove it. Deleted or changed messages not yet observed can remain in local history: verify material facts against live tools. Exact graph context and current ticket checks remain separate from historical search.
|
|
77
|
+
|
|
78
|
+
The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory/semantic-recall), [skills](https://mastra.ai/docs/skills), [harness](https://mastra.ai/docs/harness/overview), and [workflow](https://mastra.ai/docs/workflows/overview) documentation. The two built-in skills use `createSkill()` and are exposed to native coding runtimes through MCP. Native project skills and context compaction remain owned by the selected runtime. Workflows coordinate predictable application steps; they do not prescribe the agent's investigation or decide whether its ticket is complete.
|
|
79
|
+
|
|
65
80
|
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
66
81
|
|
|
67
82
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
68
83
|
|
|
69
84
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
70
85
|
|
|
71
|
-
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and
|
|
86
|
+
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and agent-controlled turn completion. Maintainers must run `npm run certify` before publishing.
|
|
72
87
|
|
|
73
88
|
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher subscribes only to the organization verified from its own authenticated agent profile and restores that subscription after reconnect. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
|
|
74
89
|
|
|
@@ -83,6 +98,10 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
83
98
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
84
99
|
```
|
|
85
100
|
|
|
101
|
+
Agents choose their tools, investigation steps, context management, and when to end a turn. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
|
|
102
|
+
|
|
103
|
+
The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart. Claude, Codex, and OpenCode keep context within a ticket or conversation; native runtimes handle compaction. A stalled-session watchdog resets when activity arrives, so ongoing work is not stopped by a fixed task duration. Ownership, cancellation, delivery deduplication, credential injection, and repository permissions remain enforced.
|
|
104
|
+
|
|
86
105
|
The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Replies to one thread stay serialized while up to three independent threads run concurrently, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
|
|
87
106
|
|
|
88
107
|
PR publishing is part of normal coding setup. `connect` authorizes the selected `--workspace` (or `~/openvisio-workspace`) for routine agent-branch pushes and installs the constrained Codex helper rule. New clones and linked worktrees inherit that workspace authority. Existing coding configurations migrate on their next watcher start. Use `--no-pr-push` during setup to opt out.
|
|
@@ -107,7 +126,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
107
126
|
|
|
108
127
|
- **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
|
|
109
128
|
- **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
|
|
110
|
-
- **
|
|
129
|
+
- **Scoped workspace access.** Chat sessions can discover advertised MCP actions and use reading, search, browsing, planning, and context tools. Local writes and shell execution require a coding workspace. Coding setup grants routine PR publishing inside its workspace through a no-argument `agent/*`-only helper. Repositories outside that workspace need an exact repository grant. The helper cannot merge or push protected branches.
|
|
111
130
|
- **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
|
|
112
131
|
|
|
113
132
|
## Requirements
|
|
@@ -156,4 +175,8 @@ Watchers running this version record bounded, credential-redacted activity under
|
|
|
156
175
|
|
|
157
176
|
`npm test` includes the executable BYO scenario catalogue. `npm run test:scenarios -- --report /tmp/byo-results.json` writes every case, result, category, and duration. Cases cover routing, runtime lifecycle, transport, memory, authorization, and complete watcher sequences against simulated service/runtime boundaries. They do not invoke paid models or mutate a live workspace.
|
|
158
177
|
|
|
178
|
+
`node scripts/smoke-context-search.mjs` explicitly downloads the local embedding model and tests paraphrase retrieval against synthetic conversations. Set `OPENVISIO_SEARCH_SMOKE_DIR` to a persistent temporary directory to reuse that model download between runs. Ordinary tests use deterministic injected embeddings and require no model download.
|
|
179
|
+
|
|
180
|
+
Dependency review (2026-09-10): `@mastra/fastembed@1.3.1` pins `onnxruntime-node@1.26.0`, whose installer depends on `adm-zip@0.5.x`. npm reports a [high-severity ZIP allocation issue](https://github.com/advisories/GHSA-xcpc-8h2w-3j85) and a [symlink extraction issue](https://github.com/advisories/GHSA-vwc7-r8mq-g2x9), propagated to the parent dependencies. Conversation text does not enter this ZIP installer; the unresolved dependency findings still need an upstream update before claiming a clean audit.
|
|
181
|
+
|
|
159
182
|
The watcher now stores pending threaded replies before posting and retries their authoritative delivery checks without rerunning the model. Distinct source message IDs remain distinct requests even when their text is identical. Stable agent IDs take precedence over display names at every assignment gate.
|
package/USER_GUIDE.md
CHANGED
|
@@ -4,6 +4,14 @@ Connect your coding agent to your team, let it handle assigned work, and follow
|
|
|
4
4
|
|
|
5
5
|
Already connected? Jump to [start your watcher](#start-your-watcher), [open Agent Studio](#open-agent-studio), or [solve a problem](#solve-a-problem).
|
|
6
6
|
|
|
7
|
+
## Finding work and earlier conversations
|
|
8
|
+
|
|
9
|
+
You can tell your agent “I have a task for you” or ask it to check its assignments. Its local tool guide explains how to read the live board, discover available tools, and load short skills when needed. Looking up assignments does not itself claim or complete a ticket.
|
|
10
|
+
|
|
11
|
+
For earlier context, ask something like “What did we decide about credential storage?” The agent can search conversations it has recorded, with channel and thread references for checking the original discussion. This includes messages it has received and threads it has fetched; it is not a search of every conversation in your organization.
|
|
12
|
+
|
|
13
|
+
The first semantic search downloads a local embedding model. Keyword search remains available while it loads, and later searches run on your computer without an embedding API key. Search history survives watcher restarts. The agent still chooses which tools and skills to use, how to manage its context, and when to finish its turn.
|
|
14
|
+
|
|
7
15
|
## Before you begin
|
|
8
16
|
|
|
9
17
|
You need Node.js **22.13 or newer**, access to your OpenVisio team, and a supported coding agent: Claude Code, Codex, or OpenCode. Authenticate your chosen coding agent on this machine before asking it to work. Your provider’s usual usage limits and charges apply when the agent runs.
|
|
@@ -91,7 +99,7 @@ The demo makes no model calls. Close it with `Ctrl+C` in its terminal and run St
|
|
|
91
99
|
### Find a result
|
|
92
100
|
|
|
93
101
|
1. Select an agent in the sidebar.
|
|
94
|
-
2. Select **Cycles**. A cycle is
|
|
102
|
+
2. Select **Cycles**. A cycle is one agent turn, including its tool calls. The agent chooses when to finish; ticket completion is a separate action.
|
|
95
103
|
3. Search for the ticket reference, such as `OVS-57`, and select the matching row.
|
|
96
104
|
4. Switch between **Plan**, **Commands**, and **Response** to inspect steps, tool calls, and the final response. Expand **Runtime details** or **Cycle history** for more context.
|
|
97
105
|
|
|
@@ -113,7 +121,7 @@ Watchers running version **0.21.0 or newer** apply saved settings to the next re
|
|
|
113
121
|
| --- | --- |
|
|
114
122
|
| Live | Your browser is connected to Studio. Each watcher has its own connection status. |
|
|
115
123
|
| Queued / Active | Work is waiting or running in the current watcher run. |
|
|
116
|
-
| Completed | The selected action or
|
|
124
|
+
| Completed | The selected action finished, or the agent ended its turn. This does not automatically mark a ticket done. Review the final response for the outcome. |
|
|
117
125
|
| Blocked / Failed / Timed out | Read the outcome and watcher log. Resolve the stated cause before requesting more work. |
|
|
118
126
|
| Offline | The watcher stopped reporting or restarted. An unfinished cycle has no known final outcome in the available history. |
|
|
119
127
|
| No explicit plan recorded | No plan was provided in the available history. This alone is not a failure. |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@agentclientprotocol/codex-acp": "1.10.0",
|
|
44
44
|
"@mastra/acp": "0.4.1",
|
|
45
45
|
"@mastra/core": "1.64.0",
|
|
46
|
+
"@mastra/fastembed": "1.3.1",
|
|
46
47
|
"@mastra/libsql": "1.22.3",
|
|
47
48
|
"@mastra/memory": "1.28.2"
|
|
48
49
|
}
|
|
@@ -27,10 +27,10 @@ const formats = [
|
|
|
27
27
|
|
|
28
28
|
// Tool identity, runtime representation, and lane capability are independent
|
|
29
29
|
// dimensions: every row checks the actual permission boundary, not a snapshot.
|
|
30
|
-
for (const tool of [...readTools, ...coordinationTools, ...repositoryTools
|
|
30
|
+
for (const tool of [...readTools, ...coordinationTools, ...repositoryTools]) {
|
|
31
31
|
for (const mode of modes) for (const format of formats) {
|
|
32
32
|
add(`permission-${mode.name}-${format.name}-${tool}`, 'runtime-permissions', `${mode.name} handles ${tool} from ${format.name} with only that lane's authority`, async () => {
|
|
33
|
-
const permitted = mode.canCode ||
|
|
33
|
+
const permitted = mode.canCode || !localTools.includes(tool)
|
|
34
34
|
const result = acpPermissionResponse({ options: permissionOptions, toolCall: format.call(tool) }, mode)
|
|
35
35
|
assert.equal(result.outcome.optionId, permitted ? 'yes' : 'no')
|
|
36
36
|
})
|
|
@@ -317,12 +317,7 @@ for (const agent of ['codex', 'opencode']) for (const mode of modes) for (const
|
|
|
317
317
|
assert.equal(server.command, process.execPath)
|
|
318
318
|
const env = Object.fromEntries(server.env.map(({ name, value }) => [name, value]))
|
|
319
319
|
const allowed = JSON.parse(env.OPENVISIO_CODEX_ALLOWED_TOOLS)
|
|
320
|
-
|
|
321
|
-
else {
|
|
322
|
-
assert.ok(allowed.includes('get_ticket'))
|
|
323
|
-
assert.equal(allowed.includes('update_ticket'), mode.canCoordinate)
|
|
324
|
-
assert.equal(allowed.includes('write_codebase_file'), false)
|
|
325
|
-
}
|
|
320
|
+
assert.equal(allowed, null, 'Scheduling lanes do not hide advertised capabilities')
|
|
326
321
|
}
|
|
327
322
|
if (agent === 'opencode') {
|
|
328
323
|
const value = JSON.parse(config.env.OPENCODE_CONFIG_CONTENT)
|
|
@@ -353,10 +348,10 @@ for (const change of ['same', 'cwd', 'disabled-tools', 'model']) {
|
|
|
353
348
|
}
|
|
354
349
|
|
|
355
350
|
for (const localTool of localTools) {
|
|
356
|
-
add(`opencode-local-denial-${localTool}`, 'runtime-session-config', `OpenCode reply configuration
|
|
351
|
+
add(`opencode-local-denial-${localTool}`, 'runtime-session-config', `OpenCode reply configuration scopes ${localTool} access globally and at agent level`, async () => {
|
|
357
352
|
const config = buildOpencodeConfig({ canCode: false, mcpUrl: 'https://fixture.invalid/mcp', mcpHeaders: headers })
|
|
358
|
-
assert.equal(config.permission[localTool], 'deny')
|
|
359
|
-
assert.equal(config.agent['openvisio-reply'].permission[localTool], 'deny')
|
|
353
|
+
assert.equal(config.permission[localTool], ['read', 'task'].includes(localTool) ? 'allow' : 'deny')
|
|
354
|
+
assert.equal(config.agent['openvisio-reply'].permission[localTool], ['read', 'task'].includes(localTool) ? 'allow' : 'deny')
|
|
360
355
|
assert.equal(config.mcp['openvisio-team'].oauth, false)
|
|
361
356
|
assert.equal(config.mcp['openvisio-team'].headers['x-agent-api-key'], 'fixture-secret-key')
|
|
362
357
|
})
|
|
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels } = {}) {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
|
|
21
21
|
const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
|
|
22
22
|
const ticket = { id: 77, project_id: 1, slug: 'OPEN-77', title: 'Repair the component', agent_id: 7, status: 'In Progress', type_id: 1, updated_at: 'revision-1' }
|
|
@@ -33,6 +33,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
33
33
|
if (name === 'list_task_types') return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] }
|
|
34
34
|
if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: getTicket ? getTicket(ticket) : { ...ticket } } }
|
|
35
35
|
if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
|
|
36
|
+
if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
|
|
36
37
|
if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
|
|
37
38
|
if (name === 'list_activity') return { activities: [] }
|
|
38
39
|
if (name === 'list_message_thread') {
|
package/scripts/certify.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const assertions = [
|
|
|
55
55
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
|
56
56
|
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
57
57
|
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
|
|
58
|
-
['
|
|
58
|
+
['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
|
|
59
59
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
60
60
|
['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
61
61
|
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment' ? { content } : { text: content }")],
|
|
@@ -75,7 +75,7 @@ const assertions = [
|
|
|
75
75
|
['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
|
|
76
76
|
['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
|
|
77
77
|
['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && codexConfig.includes('disabled_tools = [')],
|
|
78
|
-
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')
|
|
78
|
+
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')],
|
|
79
79
|
['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
|
|
80
80
|
['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
|
|
81
81
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
@@ -106,18 +106,18 @@ const assertions = [
|
|
|
106
106
|
['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
|
|
107
107
|
['bare-agent posts have a live conversation guard', bareRun.includes('const canPostMessage') && bareRun.includes('{ canPostMessage }') && bareRuntime.includes('reply suppressed because the live thread was redirected, cancelled, or already answered')],
|
|
108
108
|
['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
|
|
109
|
-
['completion
|
|
110
|
-
['
|
|
109
|
+
['completion follows the native agent turn without a forced recovery', watcher.includes("cycleControl.finishedBy = 'agent'") && !watcher.includes('missingRuntimeWorkEvidence') && !watcher.includes('cycle.recovery')],
|
|
110
|
+
['runtime failures persist without restoring legacy evidence gates', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("replayState.completionPolicy === 'agent'") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)')],
|
|
111
111
|
['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && mastraHarness.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
|
|
112
112
|
['ACP evidence requires completed tools', mastraHarness.includes("if (tool.status !== 'completed') continue") && mastraHarness.includes('codexPolicyBlock(json(tool.rawOutput))')],
|
|
113
113
|
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
|
114
114
|
['OpenCode ACP isolates server identity and reply permissions', mastraHarness.includes('OPENCODE_CONFIG_CONTENT') && mastraHarness.includes('buildOpencodeConfig({ canCode })') && mastraHarness.includes('mcpServers,')],
|
|
115
|
-
['
|
|
115
|
+
['agents can discover tools and choose context', watcher.includes('TOOL DISCOVERY') && watcher.includes('context compaction')],
|
|
116
116
|
['ACP tool failures retain sanitized diagnostics', mastraHarness.includes('for (const [name, detail] of errors)') && mastraHarness.includes('redact(detail)')],
|
|
117
|
-
['
|
|
117
|
+
['tool telemetry remains separate from the native final response', watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('outputText: finalText')],
|
|
118
118
|
['ACP prose cannot masquerade as repository evidence', mastraHarness.includes("if (event.type === 'text') { outputText += event.text; continue }") && mastraHarness.includes("if (tool.status !== 'completed') continue")],
|
|
119
119
|
['ACP MCP failures retain sanitized per-call diagnostics', mastraHarness.includes('mcpErrorDetails: Object.fromEntries(errors)') && mastraHarness.includes("join('[redacted]')")],
|
|
120
|
-
['
|
|
120
|
+
['same-source context stays warm across native turns', !watcher.includes('MAX_TURNS') && mastraHarness.includes('persistSession: true')],
|
|
121
121
|
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
122
122
|
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
|
123
123
|
['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
|
|
@@ -129,17 +129,17 @@ const assertions = [
|
|
|
129
129
|
['policy rejection cannot close a work cycle', mastraHarness.includes("subtype: policyBlock ? 'blocked' : 'ok'") && watcher.includes("result?.subtype === 'blocked'")],
|
|
130
130
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
131
131
|
['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
|
|
132
|
-
['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('
|
|
132
|
+
['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
|
|
133
133
|
['Codex publishes local branches with the constrained helper', watcher.includes('CODEX PR DELIVERY') && watcher.includes('openvisio-agent push-pr-branch') && watcher.includes('Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback') && events.includes('const codebaseMutation')],
|
|
134
134
|
['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', identity.remote, destination")],
|
|
135
135
|
['PR push helper restricts branch and destination within setup authority', prPush.includes('isSafeAgentBranch(branch)') && prPush.includes('policyAllows(') && prPush.includes('accepts no force, remote, or ref args')],
|
|
136
136
|
['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
|
|
137
137
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("The local runtime rejected")],
|
|
138
138
|
['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
139
|
-
['
|
|
139
|
+
['agents retain control of their response after tool failures', watcher.includes('agent ended its turn with tool diagnostics') && !watcher.includes('blockingReplyMcpErrors')],
|
|
140
140
|
['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'")],
|
|
141
|
-
['chat ACP
|
|
142
|
-
['
|
|
141
|
+
['chat ACP discovers advertised tools through the authenticated proxy', mastraHarness.includes('opaqueMcpApproval') && !mastraHarness.includes('CHAT_SAFE_MCP_READS') && codexProxy.includes('export function toolAllowed')],
|
|
142
|
+
['runtime failures have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
143
143
|
['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
|
|
144
144
|
['agent messages use first-person voice', watcher.includes('FIRST-PERSON VOICE') && watcher.includes("I'm blocked") && !watcher.includes('Alex is blocked') && !watcher.includes('Alex is paused')],
|
|
145
145
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Explicit opt-in integration check: downloads Mastra's local embedding model.
|
|
2
|
+
// Uses synthetic conversations only. Keep OPENVISIO_SEARCH_SMOKE_DIR between
|
|
3
|
+
// runs to reuse the model download; otherwise the temporary cache is removed.
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { createContextSearch } from '../src/context-search.mjs'
|
|
9
|
+
|
|
10
|
+
const directory = process.env.OPENVISIO_SEARCH_SMOKE_DIR || mkdtempSync(join(tmpdir(), 'openvisio-semantic-smoke-'))
|
|
11
|
+
mkdirSync(directory, { recursive: true })
|
|
12
|
+
const search = createContextSearch({ directory, resourceId: 'synthetic-smoke' })
|
|
13
|
+
try {
|
|
14
|
+
search.remember({ key: 'security', text: 'Keep production credentials in the vault and rotate them monthly.', refs: { channelId: 1, messageId: 1 } })
|
|
15
|
+
search.remember({ key: 'lunch', text: 'The team is ordering pizza for lunch on Friday.', refs: { channelId: 1, messageId: 2 } })
|
|
16
|
+
search.remember({ key: 'work', text: 'Alex owns the pending ticket to repair the login form.', refs: { channelId: 1, messageId: 3 } })
|
|
17
|
+
await search.search({ query: 'How should we protect passwords?', waitMs: 1 })
|
|
18
|
+
await search.settled()
|
|
19
|
+
const result = await search.search({ query: 'How should we protect passwords?', limit: 1, waitMs: 10_000 })
|
|
20
|
+
assert.equal(result.mode, 'hybrid', JSON.stringify(result))
|
|
21
|
+
assert.equal(result.matches[0]?.key, 'security', JSON.stringify(result))
|
|
22
|
+
assert.equal(result.matches[0]?.match, 'semantic', 'Paraphrase has no overlapping keywords')
|
|
23
|
+
const assignments = await search.search({ query: 'Who is fixing the sign-in bug?', limit: 1, waitMs: 10_000 })
|
|
24
|
+
assert.equal(assignments.matches[0]?.key, 'work', JSON.stringify(assignments))
|
|
25
|
+
assert.equal(assignments.matches[0]?.match, 'semantic')
|
|
26
|
+
const vague = await search.search({ query: 'What assignments need attention?', limit: 1, waitMs: 10_000 })
|
|
27
|
+
assert.equal(vague.matches.length, 0, 'Weak similarity must not invent an answer to a vague request')
|
|
28
|
+
console.log('Real local embeddings retrieved the security conversation from a paraphrased query.')
|
|
29
|
+
console.log('Real local embeddings also recovered the pending work discussion by intent.')
|
|
30
|
+
} finally {
|
|
31
|
+
await search.close()
|
|
32
|
+
if (!process.env.OPENVISIO_SEARCH_SMOKE_DIR) rmSync(directory, { recursive: true, force: true })
|
|
33
|
+
}
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
|
|
4
5
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
6
|
+
import { contextTools, contextToolNames, callContextService, validateContextArguments } from './context-tools.mjs'
|
|
5
7
|
|
|
6
8
|
export function toolWithoutCredentialInputs(tool) {
|
|
7
9
|
const copy = structuredClone(tool)
|
|
@@ -78,9 +80,26 @@ export function runCodexMcpProxy() {
|
|
|
78
80
|
const url = process.env.OPENVISIO_CODEX_MCP_URL
|
|
79
81
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
80
82
|
const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
|
|
83
|
+
const capabilities = { canCode: process.env.OPENVISIO_CAN_CODE === 'true', workspaceAvailable: process.env.OPENVISIO_WORKSPACE_AVAILABLE === 'true' }
|
|
84
|
+
const contextEnvironment = { OPENVISIO_CONTEXT_URL: process.env.OPENVISIO_CONTEXT_URL, OPENVISIO_CONTEXT_TOKEN: process.env.OPENVISIO_CONTEXT_TOKEN }
|
|
85
|
+
const localContext = contextEnvironment.OPENVISIO_CONTEXT_URL && contextEnvironment.OPENVISIO_CONTEXT_TOKEN ? contextTools : []
|
|
86
|
+
let available = []
|
|
81
87
|
const { disabled, allowed: allowedTools } = proxyToolPolicyFromEnvironment()
|
|
82
88
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
83
89
|
const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
|
|
90
|
+
const discoverTools = async () => {
|
|
91
|
+
const controls = [...runtimeControlTools(capabilities), ...localContext]
|
|
92
|
+
let remote
|
|
93
|
+
try { remote = await client.listTools() }
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!controls.length) throw error
|
|
96
|
+
process.stderr.write('OpenVisio backend discovery is unavailable; local capabilities remain available.\n')
|
|
97
|
+
remote = []
|
|
98
|
+
}
|
|
99
|
+
const tools = [...remote.filter((tool) => tool.name !== WORK_SESSION_TOOL && !contextToolNames.has(tool.name)), ...controls]
|
|
100
|
+
available = tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs)
|
|
101
|
+
return available
|
|
102
|
+
}
|
|
84
103
|
const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
|
|
85
104
|
const fail = (id, error, request) => {
|
|
86
105
|
const message = String(error?.message || error || 'MCP bridge error').split(apiKey).join('[redacted]')
|
|
@@ -109,11 +128,24 @@ export function runCodexMcpProxy() {
|
|
|
109
128
|
} else if (request.method === 'ping') {
|
|
110
129
|
reply(id, {})
|
|
111
130
|
} else if (request.method === 'tools/list') {
|
|
112
|
-
|
|
113
|
-
reply(id, { tools: tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs) })
|
|
131
|
+
reply(id, { tools: await discoverTools() })
|
|
114
132
|
} else if (request.method === 'tools/call') {
|
|
115
133
|
const name = String(request.params?.name || '')
|
|
116
134
|
if (!toolAllowed(name, { disabled, allowed: allowedTools })) throw new Error('This tool is disabled for the current delivery lane.')
|
|
135
|
+
if (name === WORK_SESSION_TOOL) { reply(id, requestWorkSession(request.params?.arguments, capabilities)); return }
|
|
136
|
+
if (contextToolNames.has(name)) {
|
|
137
|
+
if (!localContext.length) throw new Error('Local context tools are unavailable')
|
|
138
|
+
const args = validateContextArguments(name, request.params?.arguments)
|
|
139
|
+
if (name === 'openvisio_find_tools') await discoverTools()
|
|
140
|
+
const data = await callContextService(contextEnvironment, { name, arguments: args, availableNames: available.map((tool) => tool.name) })
|
|
141
|
+
if (name === 'openvisio_find_tools') {
|
|
142
|
+
// The current proxy is the authority on runtime availability and the
|
|
143
|
+
// sanitized schema, even if the backend catalog changed mid-search.
|
|
144
|
+
const catalog = new Map(available.map((tool) => [tool.name, tool]))
|
|
145
|
+
data.matches = (data.matches || []).filter((tool) => catalog.has(tool.name)).map((tool) => ({ ...catalog.get(tool.name), match: tool.match }))
|
|
146
|
+
}
|
|
147
|
+
reply(id, { content: [{ type: 'text', text: JSON.stringify(data) }] }); return
|
|
148
|
+
}
|
|
117
149
|
let result
|
|
118
150
|
try { result = await client.callTool(name, request.params?.arguments || {}) }
|
|
119
151
|
catch (error) {
|
|
@@ -122,6 +154,10 @@ export function runCodexMcpProxy() {
|
|
|
122
154
|
if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
|
|
123
155
|
result = await client.callTool(name, request.params?.arguments || {})
|
|
124
156
|
}
|
|
157
|
+
if (localContext.length && ['list_message_thread', 'post_message'].includes(name) && !result?.isError) {
|
|
158
|
+
try { await callContextService(contextEnvironment, { action: 'observe', name, args: request.params?.arguments || {}, result }, 1000) }
|
|
159
|
+
catch { /* optional indexing cannot fail the successful backend action */ }
|
|
160
|
+
}
|
|
125
161
|
reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
|
|
126
162
|
} else if (id != null) {
|
|
127
163
|
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, existsSync, chmodSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { Worker } from 'node:worker_threads'
|
|
5
|
+
import { LibSQLVector } from '@mastra/libsql'
|
|
6
|
+
import { writeJson } from './lib.mjs'
|
|
7
|
+
|
|
8
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex')
|
|
9
|
+
const stopWords = new Set('a an and are as at be been by can could did do does for from had has have how i if in is it its me my of on or our should that the their them there these they this to was we were what when where which who will with would you your'.split(' '))
|
|
10
|
+
const words = (text) => new Set((String(text).toLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) || []).filter((word) => !stopWords.has(word)))
|
|
11
|
+
const waitBriefly = async (promise, ms) => {
|
|
12
|
+
let timer
|
|
13
|
+
try { return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(resolve, ms) })]) }
|
|
14
|
+
finally { clearTimeout(timer) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createLocalEmbedder({ cacheDir }) {
|
|
18
|
+
let worker, sequence = 0, closed = false
|
|
19
|
+
const pending = new Map()
|
|
20
|
+
const rejectAll = () => {
|
|
21
|
+
for (const { reject, timer } of pending.values()) { clearTimeout(timer); reject(new Error('Local embedding unavailable')) }
|
|
22
|
+
pending.clear()
|
|
23
|
+
const previous = worker; worker = null
|
|
24
|
+
void previous?.terminate()
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
embed(texts, { query = false } = {}) {
|
|
28
|
+
if (closed) return Promise.reject(new Error('Local embedding closed'))
|
|
29
|
+
if (!worker) {
|
|
30
|
+
worker = new Worker(new URL('./embedding-worker.mjs', import.meta.url), { workerData: { cacheDir } })
|
|
31
|
+
const current = worker
|
|
32
|
+
worker.on('error', () => { if (worker === current) rejectAll() })
|
|
33
|
+
worker.on('exit', () => { if (worker === current) rejectAll() })
|
|
34
|
+
worker.on('message', ({ id, vectors, error }) => {
|
|
35
|
+
const job = pending.get(id)
|
|
36
|
+
if (!job) return
|
|
37
|
+
pending.delete(id); clearTimeout(job.timer)
|
|
38
|
+
if (error) job.reject(new Error(error)); else job.resolve(vectors)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const id = ++sequence
|
|
43
|
+
pending.set(id, { resolve, reject, timer: setTimeout(rejectAll, 120_000) })
|
|
44
|
+
worker.postMessage({ id, texts, query })
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
async close() { closed = true; const previous = worker; rejectAll(); await previous?.terminate() },
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Separate from the exact coordination graph: historical messages are searchable
|
|
52
|
+
// evidence, never instructions or the source of live assignment/completion state.
|
|
53
|
+
export function createContextSearch({ directory, resourceId, embedder, vector, maxRecords = 5000, now = Date.now }) {
|
|
54
|
+
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || maxRecords > 20_000) throw new Error('Invalid conversation retention capacity')
|
|
55
|
+
const scope = hash(String(resourceId)).slice(0, 24)
|
|
56
|
+
const file = join(directory, `context-${scope}.json`)
|
|
57
|
+
const database = join(directory, `context-${scope}.db`)
|
|
58
|
+
const indexName = 'context_bge_small_en_v15_retrieval'
|
|
59
|
+
const model = embedder || createLocalEmbedder({ cacheDir: join(directory, `embeddings-${scope}`) })
|
|
60
|
+
const vectors = vector || new LibSQLVector({ id: `context-${scope}`, url: `file:${database}`, maxRetries: 1 })
|
|
61
|
+
let raw
|
|
62
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')) } catch { /* first run */ }
|
|
63
|
+
const records = new Map((raw?.version === 1 && raw?.resourceId === resourceId && Array.isArray(raw.records) ? raw.records : [])
|
|
64
|
+
.filter((entry) => entry?.id && typeof entry.text === 'string').map((entry) => [entry.id, entry]))
|
|
65
|
+
if (raw?.indexName !== indexName || !vector && !existsSync(database)) for (const entry of records.values()) delete entry.indexedHash
|
|
66
|
+
let closed = false, indexing = null, initialized = false, retryAfter = 0, flushTimer
|
|
67
|
+
let state = 'idle'
|
|
68
|
+
const persist = () => { clearTimeout(flushTimer); writeJson(file, { version: 1, resourceId, indexName, records: [...records.values()] }, true) }
|
|
69
|
+
const schedulePersist = () => { clearTimeout(flushTimer); flushTimer = setTimeout(() => { try { persist() } catch { /* search still works in memory */ } }, 100); flushTimer.unref() }
|
|
70
|
+
const forget = (key) => {
|
|
71
|
+
for (const [id, entry] of records) if (entry.key === key) records.delete(id)
|
|
72
|
+
// Stale vectors are rejected against canonical records, including in-flight
|
|
73
|
+
// writes. Remove them on the next indexing pass as well.
|
|
74
|
+
schedulePersist()
|
|
75
|
+
}
|
|
76
|
+
const remember = ({ key, kind = 'conversation', text, refs = {}, author = '', timestamp = '', ...rest }) => {
|
|
77
|
+
if (closed || !key || typeof text !== 'string' || !text.trim()) return
|
|
78
|
+
const bounded = text.trim().slice(0, 24_000)
|
|
79
|
+
const chunks = []
|
|
80
|
+
for (let offset = 0; offset < bounded.length; offset += 1000) chunks.push(bounded.slice(offset, offset + 1200))
|
|
81
|
+
const ids = new Set()
|
|
82
|
+
for (let part = 0; part < chunks.length; part++) {
|
|
83
|
+
const id = hash(`${scope}:${key}:${part}`)
|
|
84
|
+
ids.add(id)
|
|
85
|
+
const textHash = hash(JSON.stringify([chunks[part], kind, refs]))
|
|
86
|
+
const previous = records.get(id)
|
|
87
|
+
records.set(id, { id, key, kind, text: chunks[part], refs, author, timestamp, part, textHash,
|
|
88
|
+
indexedHash: previous?.textHash === textHash ? previous.indexedHash : undefined,
|
|
89
|
+
updatedAt: now(), truncated: text.length > bounded.length, ...(rest.name ? { name: rest.name } : {}) })
|
|
90
|
+
}
|
|
91
|
+
for (const [id, entry] of records) if (entry.key === key && !ids.has(id)) records.delete(id)
|
|
92
|
+
if (records.size > maxRecords) {
|
|
93
|
+
const oldest = [...records.values()].sort((a, b) => a.updatedAt - b.updatedAt).slice(0, records.size - maxRecords)
|
|
94
|
+
for (const entry of oldest) records.delete(entry.id)
|
|
95
|
+
}
|
|
96
|
+
schedulePersist()
|
|
97
|
+
if (state !== 'idle') void startIndexing()
|
|
98
|
+
}
|
|
99
|
+
const startIndexing = () => {
|
|
100
|
+
if (closed || indexing || now() < retryAfter) return indexing || Promise.resolve()
|
|
101
|
+
indexing = (async () => {
|
|
102
|
+
state = 'indexing'
|
|
103
|
+
if (!initialized) {
|
|
104
|
+
await vectors.createIndex({ indexName, dimension: 384, metric: 'cosine' })
|
|
105
|
+
if (!vector) chmodSync(database, 0o600)
|
|
106
|
+
initialized = true
|
|
107
|
+
}
|
|
108
|
+
// Bound disk growth to retained canonical records. Metadata never contains
|
|
109
|
+
// conversation text or arbitrary tool outputs.
|
|
110
|
+
await vectors.deleteVectors({ indexName, filter: { id: { $nin: [...records.keys()] } } })
|
|
111
|
+
while (!closed) {
|
|
112
|
+
const batch = [...records.values()].filter((entry) => entry.indexedHash !== entry.textHash).slice(0, 16)
|
|
113
|
+
if (!batch.length) break
|
|
114
|
+
const embeddings = await model.embed(batch.map((entry) => entry.text))
|
|
115
|
+
if (closed) return
|
|
116
|
+
if (embeddings.length !== batch.length || embeddings.some((v) => v.length !== 384 || v.some((n) => !Number.isFinite(n)))) throw new Error('Invalid local embeddings')
|
|
117
|
+
await vectors.upsert({ indexName, ids: batch.map((entry) => entry.id), vectors: embeddings,
|
|
118
|
+
metadata: batch.map((entry) => ({ id: entry.id, key: entry.key, kind: entry.kind, hash: entry.textHash, ...entry.refs })) })
|
|
119
|
+
for (const entry of batch) if (records.get(entry.id)?.textHash === entry.textHash) records.get(entry.id).indexedHash = entry.textHash
|
|
120
|
+
schedulePersist()
|
|
121
|
+
}
|
|
122
|
+
state = 'ready'
|
|
123
|
+
})().catch(() => { state = 'unavailable'; retryAfter = now() + 60_000 }).finally(() => { indexing = null })
|
|
124
|
+
return indexing
|
|
125
|
+
}
|
|
126
|
+
const search = async ({ query, kind = 'conversation', channelId, threadId, keys, limit = 6, waitMs = 1000 }) => {
|
|
127
|
+
if (typeof query !== 'string' || !query.trim() || query.length > 2000) throw new Error('Search query must contain 1–2000 characters')
|
|
128
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 12) throw new Error('Search limit must be between 1 and 12')
|
|
129
|
+
const eligible = (entry) => entry && entry.kind === kind && (!keys || keys.includes(entry.key)) && (channelId == null || String(entry.refs.channelId) === String(channelId)) && (threadId == null || String(entry.refs.threadId) === String(threadId))
|
|
130
|
+
const tokens = words(query)
|
|
131
|
+
const ranked = [...records.values()].filter(eligible).map((entry) => {
|
|
132
|
+
const terms = words(entry.text)
|
|
133
|
+
return { entry, lexical: [...tokens].reduce((n, token) => n + (terms.has(token) ? 1 : 0), 0) / Math.max(1, tokens.size) }
|
|
134
|
+
}).filter((hit) => hit.lexical > 0).sort((a, b) => b.lexical - a.lexical || b.entry.updatedAt - a.entry.updatedAt)
|
|
135
|
+
const scores = new Map(ranked.map((hit, rank) => [hit.entry.id, { entry: hit.entry, score: 1 / (60 + rank), match: 'keyword' }]))
|
|
136
|
+
await waitBriefly(startIndexing(), waitMs)
|
|
137
|
+
let mode = 'keyword'
|
|
138
|
+
if (state === 'ready') {
|
|
139
|
+
try {
|
|
140
|
+
const result = await waitBriefly((async () => {
|
|
141
|
+
const [queryVector] = await model.embed([query], { query: true })
|
|
142
|
+
return vectors.query({ indexName, queryVector, topK: Math.min(maxRecords, limit * 8), minScore: 0.45,
|
|
143
|
+
filter: { kind, ...(keys ? { key: { $in: keys } } : {}), ...(channelId == null ? {} : { channelId }), ...(threadId == null ? {} : { threadId }) } })
|
|
144
|
+
})(), waitMs)
|
|
145
|
+
if (result) {
|
|
146
|
+
mode = 'hybrid'
|
|
147
|
+
result.forEach((hit, rank) => {
|
|
148
|
+
const entry = records.get(hit.id)
|
|
149
|
+
if (!eligible(entry) || entry.textHash !== hit.metadata?.hash) return
|
|
150
|
+
const previous = scores.get(hit.id)
|
|
151
|
+
scores.set(hit.id, { entry, score: (previous?.score || 0) + 1 / (60 + rank), match: previous ? 'hybrid' : 'semantic' })
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
} catch { state = 'unavailable'; retryAfter = now() + 60_000 }
|
|
155
|
+
}
|
|
156
|
+
const seen = new Set()
|
|
157
|
+
const matches = [...scores.values()].filter(({ entry }) => records.get(entry.id)?.textHash === entry.textHash && eligible(records.get(entry.id)))
|
|
158
|
+
.map((hit) => ({ ...hit, entry: records.get(hit.entry.id) })).sort((a, b) => b.score - a.score).filter(({ entry }) => {
|
|
159
|
+
if (seen.has(entry.key)) return false
|
|
160
|
+
seen.add(entry.key); return true
|
|
161
|
+
}).slice(0, limit).map(({ entry, match }) => ({ key: entry.key, text: entry.text, refs: entry.refs, author: entry.author,
|
|
162
|
+
timestamp: entry.timestamp, observedAt: new Date(entry.updatedAt).toISOString(), match, part: entry.part, truncated: entry.truncated, ...(entry.name ? { name: entry.name } : {}) }))
|
|
163
|
+
return { mode, embeddingStatus: state, coverage: 'Locally recorded messages visible to this agent; not the entire team archive.', matches }
|
|
164
|
+
}
|
|
165
|
+
return { remember, forget, search, async settled() { await indexing; persist() }, async close() {
|
|
166
|
+
closed = true; clearTimeout(flushTimer); await model.close?.(); await indexing; persist(); await vectors.close?.()
|
|
167
|
+
} }
|
|
168
|
+
}
|