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 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 runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
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
- - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. 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.
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 a group of actions for one unit of work.
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 cycle reported completion. Review the final result and ticket. |
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.21.1",
4
- "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
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, ...localTools]) {
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 || readTools.includes(tool) || (mode.canCoordinate && coordinationTools.includes(tool))
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
- if (mode.canCode) assert.equal(allowed, null)
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 denies inherited ${localTool} access globally and at agent level`, async () => {
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') {
@@ -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
- ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
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') && watcher.includes('missingRuntimeWorkEvidence(result')],
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 has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
110
- ['failed evidence revisions persist and suppress self-triggered retries', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("failedTaskVersions.set(key, 'pending')") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)') && watcher.includes('ticket paused until its revision changes')],
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
- ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && ['get_marching_orders', 'poll_inbox', 'get_resource', 'list_mcp_resources', 'list_mcp_resource_templates'].every((name) => watcher.includes(name))],
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
- ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
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
- ['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
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('do not use remote codebase tools') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
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
- ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
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 allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
142
- ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
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
+ }
@@ -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
- const tools = await client.listTools()
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
+ }