openvisio-agent 0.22.0 → 0.24.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 +18 -0
- package/README.md +24 -3
- package/USER_GUIDE.md +10 -0
- package/package.json +3 -2
- package/scenarios/runtime.scenarios.mjs +1 -1
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/scripts/certify.mjs +1 -1
- package/scripts/smoke-context-search.mjs +33 -0
- package/src/activity-reporter.mjs +47 -0
- package/src/codex-mcp-proxy.mjs +35 -10
- 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/mastra-harness.mjs +8 -4
- package/src/memory.mjs +3 -3
- package/src/opencode-config.mjs +1 -1
- package/src/transport-recovery.mjs +25 -0
- package/src/watch.mjs +161 -28
- package/src/ws.mjs +3 -2
- package/studio/app.mjs +1 -1
- package/studio/guide.html +7 -0
- package/studio/style.css +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.24.0] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
|
|
6
|
+
- Enable native coding delegation through Codex multi-agent, OpenCode task, and Claude Agent/Task, with scoped child objectives and parent review.
|
|
7
|
+
- Restore working, thinking, and typing presence through the documented REST endpoint, with per-cycle ownership, throttled refresh, and failure backoff.
|
|
8
|
+
- Remove Studio input outlines and retain a subtle keyboard-focus underline.
|
|
9
|
+
- Keep accepted coding work alive across event WebSocket disconnects. Resume confirmed runtime transport interruptions in the same queue with the original workspace, thread, and partial context; verify ownership again before restarting the model.
|
|
10
|
+
- Wait through known event-channel outages without exhausting retries. Use bounded backoff for provider-only transport failures; preserve agent final responses, cancellation, policy blocks, and guarded delivery.
|
|
11
|
+
- Refresh active cycle state in the Studio journal so long tasks remain visible after their start event leaves the bounded activity window.
|
|
12
|
+
|
|
13
|
+
## [0.23.0] — 2026-09-10
|
|
14
|
+
|
|
15
|
+
- Add local semantic and keyword search over recorded conversations with source references, bounded retention, edit replacement, and backend/agent isolation.
|
|
16
|
+
- Add live assignment lookup and intent-based discovery of currently advertised tools, including a task-offer cue in every agent cycle.
|
|
17
|
+
- Expose two on-demand Mastra skills for assignment handling and conversation recall through the native runtime MCP bridge.
|
|
18
|
+
- Run local embedding inference outside the agent loop, preserve keyword fallback during model download/failure, and keep live task state authoritative.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
3
21
|
## [0.22.0] — 2026-09-10
|
|
4
22
|
|
|
5
23
|
- Carry the agent’s configured role and voice through every session, with cached identity when the roster is unavailable.
|
package/README.md
CHANGED
|
@@ -62,15 +62,30 @@ 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
|
-
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
|
|
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 publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps in the source thread or dedicated channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
|
|
74
89
|
|
|
75
90
|
Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
|
|
76
91
|
|
|
@@ -83,7 +98,7 @@ 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
|
|
|
86
|
-
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.
|
|
101
|
+
Agents choose their tools, investigation steps, context management, and when to end a turn. Coding agents can delegate independent subtasks through the runtime’s native sub-agent tools: Codex multi-agent is enabled, OpenCode permits task delegation, and Claude permits Agent/Task. The parent supplies scoped context and file ownership, works alongside children, reviews their results, and reports once. Delegation depends on the tools advertised by the installed runtime; it does not grant additional task or publishing authority. 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.
|
|
87
102
|
|
|
88
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.
|
|
89
104
|
|
|
@@ -101,6 +116,8 @@ Codex and OpenCode use Mastra ACP directly. Work sessions are retained per ticke
|
|
|
101
116
|
|
|
102
117
|
Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
|
|
103
118
|
|
|
119
|
+
An event WebSocket disconnect does not cancel accepted coding work. If the native coding runtime also reports a transport interruption, its queue entry retains the source request, workspace, and partial output. It waits for a known event-channel outage to reconnect, then verifies the ticket again and asks the agent to inspect existing work and uncertain remote writes before continuing. Provider-only interruptions use up to four retries (2, 8, 30, and 60 seconds); repeated failure then reports a blocker. Agent final responses, optional tool errors, explicit cancellation, inactivity timeouts, quota errors, and policy denials do not trigger this recovery. Coordination requests are not automatically replayed. Keep the watcher running during an outage; this recovery retains in-flight context in that watcher, not across process termination.
|
|
120
|
+
|
|
104
121
|
See `docs/BYO_PERFORMANCE_AUDIT_2026-09-08.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
|
|
105
122
|
|
|
106
123
|
`--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
|
|
@@ -160,4 +177,8 @@ Watchers running this version record bounded, credential-redacted activity under
|
|
|
160
177
|
|
|
161
178
|
`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.
|
|
162
179
|
|
|
180
|
+
`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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
|
|
163
184
|
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.
|
|
@@ -68,6 +76,8 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
|
|
|
68
76
|
|
|
69
77
|
Replies stay in their source thread. New completion messages use the agent’s dedicated channel. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
|
|
70
78
|
|
|
79
|
+
For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
|
|
80
|
+
|
|
71
81
|
## Open Agent Studio
|
|
72
82
|
|
|
73
83
|
In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
|
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.24.0",
|
|
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": {
|
|
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
|
}
|
|
@@ -321,7 +321,7 @@ for (const agent of ['codex', 'opencode']) for (const mode of modes) for (const
|
|
|
321
321
|
}
|
|
322
322
|
if (agent === 'opencode') {
|
|
323
323
|
const value = JSON.parse(config.env.OPENCODE_CONFIG_CONTENT)
|
|
324
|
-
if (mode.canCode) assert.
|
|
324
|
+
if (mode.canCode) assert.deepEqual(value.permission, { task: 'allow' })
|
|
325
325
|
else {
|
|
326
326
|
assert.equal(value.permission.bash, 'deny')
|
|
327
327
|
assert.equal(value.permission['openvisio-team-watcher_*'], 'allow')
|
|
@@ -16,11 +16,11 @@ 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, listChannels } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
|
|
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' }
|
|
23
|
-
let watcher, messageId = 100, closed = 0
|
|
23
|
+
let watcher, connection, messageId = 100, closed = 0
|
|
24
24
|
const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
|
|
25
25
|
const mcp = {
|
|
26
26
|
listTools: async () => ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name })),
|
|
@@ -71,7 +71,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
71
71
|
}
|
|
72
72
|
const start = () => {
|
|
73
73
|
watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
|
|
74
|
-
stateDir: dir, memory, mcpClient: mcp, createRunner, connect: () =>
|
|
74
|
+
stateDir: dir, memory, mcpClient: mcp, createRunner, transportRecoveryDelays, activityReporter, connect: (callbacks) => { connection = callbacks; return { close() {}, subscribeOrganization(id) { subscriptions.push(id) } } }, autoStart: false, log: (value) => logs.push(value),
|
|
75
75
|
})
|
|
76
76
|
return watcher
|
|
77
77
|
}
|
|
@@ -79,6 +79,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
79
79
|
return {
|
|
80
80
|
dir, ticket, posts, runs, calls, logs, messages, memory, subscriptions,
|
|
81
81
|
get watcher() { return watcher },
|
|
82
|
+
get connection() { return connection },
|
|
82
83
|
get closed() { return closed },
|
|
83
84
|
set threadError(value) { threadError = value },
|
|
84
85
|
start,
|
package/scripts/certify.mjs
CHANGED
|
@@ -91,7 +91,7 @@ const assertions = [
|
|
|
91
91
|
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(candidate[1].group)')],
|
|
92
92
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
93
93
|
['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
|
|
94
|
-
['presence
|
|
94
|
+
['presence follows runtime activity and stops refreshing with its cycle', watcher.includes('createActivityReporter') && watcher.includes("activity.set(data.cycleId, live.control.statusTargets, 'working')") && watcher.includes('activity.clear(cycleControl.cycleId)')],
|
|
95
95
|
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
96
96
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
97
97
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { agentStateRequest } from './events.mjs'
|
|
2
|
+
|
|
3
|
+
// Presence is ephemeral, independent of task completion and message delivery.
|
|
4
|
+
// The backend supports no "done" event; stopping refresh lets the UI TTL clear it.
|
|
5
|
+
export function createActivityReporter({ backend, apiKey, identifier, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
|
|
6
|
+
const cycles = new Map(), channels = new Map(), pending = new Set()
|
|
7
|
+
let closed = false
|
|
8
|
+
const pulse = () => {
|
|
9
|
+
if (closed || !backend) return
|
|
10
|
+
const states = new Map()
|
|
11
|
+
const priority = { thinking: 0, working: 1, typing: 2 }
|
|
12
|
+
for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
|
|
13
|
+
if (!states.has(channelId) || priority[cycle.state] > priority[states.get(channelId)]) states.set(channelId, cycle.state)
|
|
14
|
+
}
|
|
15
|
+
for (const [channelId, state] of states) {
|
|
16
|
+
const prior = channels.get(channelId)
|
|
17
|
+
if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
|
|
18
|
+
const controller = new AbortController()
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), 5_000)
|
|
20
|
+
timer.unref?.()
|
|
21
|
+
const entry = { state, at: now(), busy: true, controller }
|
|
22
|
+
channels.set(channelId, entry)
|
|
23
|
+
const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
|
|
24
|
+
const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
|
|
25
|
+
await response.body?.cancel?.()
|
|
26
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
27
|
+
}).catch(error => {
|
|
28
|
+
entry.retryAt = now() + 30_000
|
|
29
|
+
if (!closed) log('Activity update unavailable; backing off: ' + String(error?.message || 'request failed').slice(0, 100))
|
|
30
|
+
}).finally(() => { clearTimeout(timer); entry.busy = false; pending.delete(operation) })
|
|
31
|
+
pending.add(operation)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const timer = intervalMs > 0 ? setInterval(pulse, intervalMs) : null
|
|
35
|
+
timer?.unref?.()
|
|
36
|
+
return {
|
|
37
|
+
set(cycleId, channelIds, state) {
|
|
38
|
+
if (closed || !['thinking', 'working', 'typing'].includes(state)) return
|
|
39
|
+
const valid = [...new Set([...channelIds].map(Number).filter(id => Number.isSafeInteger(id) && id > 0))]
|
|
40
|
+
cycles.set(cycleId, { channels: valid, state }); pulse()
|
|
41
|
+
},
|
|
42
|
+
clear(cycleId) { cycles.delete(cycleId); pulse() },
|
|
43
|
+
pulse,
|
|
44
|
+
async settled() { await Promise.allSettled([...pending]) },
|
|
45
|
+
async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller.abort(); await Promise.allSettled([...pending]) },
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
|
|
5
5
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
6
|
+
import { contextTools, contextToolNames, callContextService, validateContextArguments } from './context-tools.mjs'
|
|
6
7
|
|
|
7
8
|
export function toolWithoutCredentialInputs(tool) {
|
|
8
9
|
const copy = structuredClone(tool)
|
|
@@ -80,9 +81,25 @@ export function runCodexMcpProxy() {
|
|
|
80
81
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
81
82
|
const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
|
|
82
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 = []
|
|
83
87
|
const { disabled, allowed: allowedTools } = proxyToolPolicyFromEnvironment()
|
|
84
88
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
85
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
|
+
}
|
|
86
103
|
const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
|
|
87
104
|
const fail = (id, error, request) => {
|
|
88
105
|
const message = String(error?.message || error || 'MCP bridge error').split(apiKey).join('[redacted]')
|
|
@@ -111,20 +128,24 @@ export function runCodexMcpProxy() {
|
|
|
111
128
|
} else if (request.method === 'ping') {
|
|
112
129
|
reply(id, {})
|
|
113
130
|
} else if (request.method === 'tools/list') {
|
|
114
|
-
|
|
115
|
-
let remote
|
|
116
|
-
try { remote = await client.listTools() }
|
|
117
|
-
catch (error) {
|
|
118
|
-
if (!controls.length) throw error
|
|
119
|
-
process.stderr.write('OpenVisio backend discovery is unavailable; local workspace continuation remains available.\n')
|
|
120
|
-
remote = []
|
|
121
|
-
}
|
|
122
|
-
const tools = [...remote.filter((tool) => tool.name !== WORK_SESSION_TOOL), ...controls]
|
|
123
|
-
reply(id, { tools: tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs) })
|
|
131
|
+
reply(id, { tools: await discoverTools() })
|
|
124
132
|
} else if (request.method === 'tools/call') {
|
|
125
133
|
const name = String(request.params?.name || '')
|
|
126
134
|
if (!toolAllowed(name, { disabled, allowed: allowedTools })) throw new Error('This tool is disabled for the current delivery lane.')
|
|
127
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
|
+
}
|
|
128
149
|
let result
|
|
129
150
|
try { result = await client.callTool(name, request.params?.arguments || {}) }
|
|
130
151
|
catch (error) {
|
|
@@ -133,6 +154,10 @@ export function runCodexMcpProxy() {
|
|
|
133
154
|
if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
|
|
134
155
|
result = await client.callTool(name, request.params?.arguments || {})
|
|
135
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
|
+
}
|
|
136
161
|
reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
|
|
137
162
|
} else if (id != null) {
|
|
138
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
|
+
}
|