@kagan-sh/opensearch 0.1.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/CONTRIBUTING.md +104 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/SKILL.md +53 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +66 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +276 -0
- package/dist/orchestrator.d.ts +60 -0
- package/dist/orchestrator.js +152 -0
- package/dist/schema.d.ts +419 -0
- package/dist/schema.js +100 -0
- package/dist/sources/code.d.ts +3 -0
- package/dist/sources/code.js +36 -0
- package/dist/sources/session.d.ts +4 -0
- package/dist/sources/session.js +75 -0
- package/dist/sources/shared.d.ts +9 -0
- package/dist/sources/shared.js +19 -0
- package/dist/sources/web.d.ts +3 -0
- package/dist/sources/web.js +53 -0
- package/dist/synth.d.ts +3 -0
- package/dist/synth.js +79 -0
- package/package.json +62 -0
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Contributing to @kagan-sh/opensearch
|
|
2
|
+
|
|
3
|
+
Keep the published README end-user facing. Put local setup, source-based plugin loading, and maintainer workflow details here.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Bun 1.2+
|
|
8
|
+
- Node 20+
|
|
9
|
+
- OpenCode CLI (`opencode`)
|
|
10
|
+
|
|
11
|
+
## Local setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun install
|
|
15
|
+
npm install -g opencode-ai
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Run the plugin from source
|
|
19
|
+
|
|
20
|
+
For local development, point `opencode.json` at the source entrypoint:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"$schema": "https://opencode.ai/config.json",
|
|
25
|
+
"plugin": ["file:///absolute/path/to/opensearch/src/index.ts"]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This keeps the published package path in `README.md` while still giving contributors a zero-publish development loop.
|
|
30
|
+
|
|
31
|
+
## Validate changes
|
|
32
|
+
|
|
33
|
+
Run the full validation pipeline before opening a pull request:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun run check
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That runs:
|
|
40
|
+
|
|
41
|
+
1. `bun run typecheck`
|
|
42
|
+
2. `bun run test`
|
|
43
|
+
3. `bun run build`
|
|
44
|
+
|
|
45
|
+
Acceptance tests are integration-heavy and boot a real OpenCode server process.
|
|
46
|
+
|
|
47
|
+
## Docs
|
|
48
|
+
|
|
49
|
+
Install docs dependencies:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python3 -m pip install -r requirements-docs.txt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run the local docs server:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mkdocs serve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Build docs with strict link validation:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
mkdocs build --strict
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Release automation
|
|
68
|
+
|
|
69
|
+
This repo uses `semantic-release` on `main`.
|
|
70
|
+
|
|
71
|
+
- Prefer Conventional Commits for merge commits and direct commits to `main`
|
|
72
|
+
- Run `bun run release:dry-run` locally if you want to preview the next release
|
|
73
|
+
- npm publishing is configured for GitHub Actions trusted publishing via OIDC for `@kagan-sh/opensearch`
|
|
74
|
+
- The npm package must be linked to this repository under the `kagan_sh` publisher account before the first release
|
|
75
|
+
- Local `semantic-release --dry-run` still fails auth checks unless GitHub and npm credentials are present; the OIDC npm path is validated in GitHub Actions, not in a regular local shell
|
|
76
|
+
|
|
77
|
+
## Project structure
|
|
78
|
+
|
|
79
|
+
- `src/index.ts` plugin entrypoint, source selection, and response assembly
|
|
80
|
+
- `src/schema.ts` zod schemas and JSON schema export
|
|
81
|
+
- `src/sources/*` source adapters for session, web, and code search
|
|
82
|
+
- `src/synth.ts` structured synthesis through `session.prompt`
|
|
83
|
+
- `tests/*` vitest coverage for schema and end-to-end plugin behavior
|
|
84
|
+
- `SKILL.md` optional skill guidance that nudges agent workflows toward `opensearch` for broad research tasks
|
|
85
|
+
|
|
86
|
+
## Testing policy
|
|
87
|
+
|
|
88
|
+
- Prefer acceptance-first coverage for behavior changes
|
|
89
|
+
- Test observable outcomes instead of internal wiring
|
|
90
|
+
- Avoid tautological tests and unnecessary mocks
|
|
91
|
+
- Mock only when the real contract cannot be exercised in runtime tests
|
|
92
|
+
|
|
93
|
+
## Release workflow
|
|
94
|
+
|
|
95
|
+
- CI workflow: `.github/workflows/ci.yml`
|
|
96
|
+
- Release workflow: `.github/workflows/release.yml`
|
|
97
|
+
- GitHub releases are automated with `semantic-release`
|
|
98
|
+
- npm publishing is ready for trusted publishing via OIDC once the package is configured on npm
|
|
99
|
+
|
|
100
|
+
## Pull requests
|
|
101
|
+
|
|
102
|
+
- Keep changes small and focused
|
|
103
|
+
- Add or update tests for behavior changes
|
|
104
|
+
- Keep `README.md` user-facing and keep contributor workflow details in `CONTRIBUTING.md`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-light.svg">
|
|
5
|
+
<img alt="OpenSearch — evidence-backed search for OpenCode" src="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-dark.svg" width="100%">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/kagan-sh/opensearch/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kagan-sh/opensearch/ci.yml?style=for-the-badge&label=CI" alt="CI"></a>
|
|
10
|
+
<a href="https://kagan-sh.github.io/opensearch/"><img src="https://img.shields.io/badge/docs-github%20pages-181717?style=for-the-badge&logo=github" alt="Docs"></a>
|
|
11
|
+
<a href="https://opensource.org/license/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"></a>
|
|
12
|
+
<a href="https://github.com/kagan-sh/opensearch/stargazers"><img src="https://img.shields.io/github/stars/kagan-sh/opensearch?style=for-the-badge" alt="Stars"></a>
|
|
13
|
+
</p>
|
|
14
|
+
<h3 align="center">
|
|
15
|
+
<a href="https://kagan-sh.github.io/opensearch/">Docs</a> ·
|
|
16
|
+
<a href="https://kagan-sh.github.io/opensearch/quickstart/">Quickstart</a> ·
|
|
17
|
+
<a href="https://kagan-sh.github.io/opensearch/reference/result-contract/">Result Contract</a> ·
|
|
18
|
+
<a href="https://github.com/kagan-sh/opensearch/issues/new?template=feature-request.yml">Feature Requests</a>
|
|
19
|
+
</h3>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
`@kagan-sh/opensearch` is an OpenCode plugin for broad investigation. It searches session history, the live web, and public code in parallel, then returns a structured evidence-backed response your agent can act on.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Add the plugin to `opencode.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"$schema": "https://opencode.ai/config.json",
|
|
32
|
+
"plugin": ["@kagan-sh/opensearch"]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
OpenCode installs npm plugins automatically at startup.
|
|
37
|
+
|
|
38
|
+
Full docs: **[kagan-sh.github.io/opensearch](https://kagan-sh.github.io/opensearch/)**.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Control runtime behavior with environment variables:
|
|
43
|
+
|
|
44
|
+
- `OPENSEARCH_SYNTH=true|false`
|
|
45
|
+
- `OPENSEARCH_DEPTH=quick|thorough`
|
|
46
|
+
- `OPENSEARCH_SOURCE_SESSION=true|false`
|
|
47
|
+
- `OPENSEARCH_SOURCE_WEB=true|false`
|
|
48
|
+
- `OPENSEARCH_SOURCE_CODE=true|false`
|
|
49
|
+
- `OPENSEARCH_WEB_KEY=<exa_api_key>`
|
|
50
|
+
- `EXA_API_KEY=<exa_api_key>`
|
|
51
|
+
|
|
52
|
+
`OPENSEARCH_WEB_KEY` takes precedence over `EXA_API_KEY`. Web search is skipped when neither key is set.
|
|
53
|
+
|
|
54
|
+
## Tool
|
|
55
|
+
|
|
56
|
+
The plugin exposes a single tool: `opensearch`.
|
|
57
|
+
|
|
58
|
+
Arguments:
|
|
59
|
+
|
|
60
|
+
- `query: string`
|
|
61
|
+
- `sources?: ("session" | "web" | "code")[]`
|
|
62
|
+
- `depth?: "quick" | "thorough"`
|
|
63
|
+
|
|
64
|
+
The tool returns strict JSON with `status`, `answer`, `confidence`, `evidence[]`, `sources[]`, `followups[]`, and `meta`.
|
|
65
|
+
|
|
66
|
+
`meta` includes:
|
|
67
|
+
|
|
68
|
+
- `sources_requested`
|
|
69
|
+
- `sources_queried`
|
|
70
|
+
- `sources_yielded`
|
|
71
|
+
- `sources_unavailable[]`
|
|
72
|
+
- `source_errors[]`
|
|
73
|
+
|
|
74
|
+
Invalid plugin config is reported explicitly instead of being ignored.
|
|
75
|
+
|
|
76
|
+
## Contributing
|
|
77
|
+
|
|
78
|
+
For local source development, validation commands, and release workflow details, see `CONTRIBUTING.md`.
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
Published docs live at `https://kagan-sh.github.io/opensearch/`. MkDocs source lives in `docs/` with site config in `mkdocs.yml`.
|
|
83
|
+
|
|
84
|
+
## Skill
|
|
85
|
+
|
|
86
|
+
The repo includes `SKILL.md` for agent environments that support installable skills. It increases the chance that agents reach for `opensearch` on research-heavy prompts, but it does not force tool selection.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opensearch
|
|
3
|
+
description: Use OpenSearch for broad, evidence-backed investigation across session history, the web, and public code when a user asks to search, research, compare sources, or gather official docs and examples.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires OpenCode with @kagan-sh/opensearch installed. Web results need OPENSEARCH_WEB_KEY or EXA_API_KEY.
|
|
6
|
+
metadata:
|
|
7
|
+
version: 0.0.1
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# OpenSearch Skill
|
|
11
|
+
|
|
12
|
+
Use this skill when the task is primarily about finding, comparing, and synthesizing evidence rather than editing code immediately.
|
|
13
|
+
|
|
14
|
+
## When to Use It
|
|
15
|
+
|
|
16
|
+
- The user says `search`, `look up`, `research`, `investigate`, or `find examples`
|
|
17
|
+
- The answer should combine project context, official docs, and public code patterns
|
|
18
|
+
- The task needs contradictory-source checking before implementation
|
|
19
|
+
- The user wants official references, best practices, or GitHub examples
|
|
20
|
+
|
|
21
|
+
## When Not to Use It
|
|
22
|
+
|
|
23
|
+
- A local `read`, `grep`, or `glob` is enough to answer the question
|
|
24
|
+
- The task is a straightforward edit in a known file
|
|
25
|
+
- The user explicitly wants a pure code change with no research step
|
|
26
|
+
|
|
27
|
+
## Default Workflow
|
|
28
|
+
|
|
29
|
+
1. Call `opensearch` first for broad or ambiguous research questions.
|
|
30
|
+
2. Prefer `depth: "thorough"` for broad or ambiguous research; keep `quick` for focused lookups.
|
|
31
|
+
3. Use all sources when external context matters; narrow sources only when the user asks for repo-local or session-local evidence.
|
|
32
|
+
4. Summarize what the tool found, highlight contradictions or gaps, then continue with implementation or recommendation.
|
|
33
|
+
|
|
34
|
+
## Query Patterns
|
|
35
|
+
|
|
36
|
+
- Official docs and examples:
|
|
37
|
+
- `React Server Components caching behavior official docs GitHub examples`
|
|
38
|
+
- Repo plus external evidence:
|
|
39
|
+
- `OpenCode plugin loading order project behavior official docs`
|
|
40
|
+
- Contradiction check:
|
|
41
|
+
- `semantic-release trusted publishing npm GitHub Actions official guidance 2026`
|
|
42
|
+
|
|
43
|
+
## Source Selection Guide
|
|
44
|
+
|
|
45
|
+
- `session` for prior decisions, earlier runs, and local conversation history
|
|
46
|
+
- `web` for official docs, changelogs, and current vendor guidance
|
|
47
|
+
- `code` for public implementation examples and real usage patterns
|
|
48
|
+
|
|
49
|
+
## Output Expectations
|
|
50
|
+
|
|
51
|
+
- Return the strongest evidence first
|
|
52
|
+
- Call out missing or unavailable sources explicitly
|
|
53
|
+
- Keep citations grounded in the tool response instead of guessing
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Config, SourceId } from "./schema";
|
|
2
|
+
export declare function defaultConfig(): Config;
|
|
3
|
+
export declare function parsePluginConfig(input: unknown): Config | undefined;
|
|
4
|
+
export declare function isSourceAvailable(config: Config, source: SourceId): boolean;
|
|
5
|
+
export declare function resolveSources(config: Config, requested?: SourceId[]): {
|
|
6
|
+
requested: ("session" | "web" | "code")[];
|
|
7
|
+
sources: ("session" | "web" | "code")[];
|
|
8
|
+
unavailable: ("session" | "web" | "code")[];
|
|
9
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ConfigSchema, SOURCE_IDS } from "./schema";
|
|
2
|
+
function parseBoolean(name, value, fallback) {
|
|
3
|
+
if (value === undefined)
|
|
4
|
+
return fallback;
|
|
5
|
+
if (value === "true")
|
|
6
|
+
return true;
|
|
7
|
+
if (value === "false")
|
|
8
|
+
return false;
|
|
9
|
+
throw new Error(`Invalid value for ${name}: expected true or false, got ${value}`);
|
|
10
|
+
}
|
|
11
|
+
function parseDepth(value) {
|
|
12
|
+
if (value === undefined || value === "quick")
|
|
13
|
+
return "quick";
|
|
14
|
+
if (value === "thorough")
|
|
15
|
+
return "thorough";
|
|
16
|
+
throw new Error(`Invalid value for OPENSEARCH_DEPTH: expected quick or thorough, got ${value}`);
|
|
17
|
+
}
|
|
18
|
+
function formatIssuePath(path) {
|
|
19
|
+
if (path.length === 0)
|
|
20
|
+
return "opensearch";
|
|
21
|
+
return `opensearch.${path.join(".")}`;
|
|
22
|
+
}
|
|
23
|
+
export function defaultConfig() {
|
|
24
|
+
return {
|
|
25
|
+
sources: {
|
|
26
|
+
session: parseBoolean("OPENSEARCH_SOURCE_SESSION", process.env.OPENSEARCH_SOURCE_SESSION, true),
|
|
27
|
+
web: {
|
|
28
|
+
enabled: parseBoolean("OPENSEARCH_SOURCE_WEB", process.env.OPENSEARCH_SOURCE_WEB, true),
|
|
29
|
+
key: process.env.OPENSEARCH_WEB_KEY ?? process.env.EXA_API_KEY,
|
|
30
|
+
},
|
|
31
|
+
code: parseBoolean("OPENSEARCH_SOURCE_CODE", process.env.OPENSEARCH_SOURCE_CODE, true),
|
|
32
|
+
},
|
|
33
|
+
depth: parseDepth(process.env.OPENSEARCH_DEPTH),
|
|
34
|
+
synth: parseBoolean("OPENSEARCH_SYNTH", process.env.OPENSEARCH_SYNTH, true),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function parsePluginConfig(input) {
|
|
38
|
+
if (!input || typeof input !== "object" || !("opensearch" in input)) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const parsed = ConfigSchema.safeParse(input.opensearch);
|
|
42
|
+
if (parsed.success)
|
|
43
|
+
return parsed.data;
|
|
44
|
+
const issues = parsed.error.issues
|
|
45
|
+
.map((issue) => `${formatIssuePath(issue.path)} ${issue.message}`)
|
|
46
|
+
.join("; ");
|
|
47
|
+
throw new Error(`Invalid opensearch config: ${issues}`);
|
|
48
|
+
}
|
|
49
|
+
export function isSourceAvailable(config, source) {
|
|
50
|
+
if (source === "session")
|
|
51
|
+
return config.sources.session;
|
|
52
|
+
if (source === "web") {
|
|
53
|
+
return config.sources.web.enabled && Boolean(config.sources.web.key);
|
|
54
|
+
}
|
|
55
|
+
return config.sources.code;
|
|
56
|
+
}
|
|
57
|
+
export function resolveSources(config, requested) {
|
|
58
|
+
const requestedSources = Array.from(new Set(requested ?? SOURCE_IDS));
|
|
59
|
+
const sources = requestedSources.filter((source) => isSourceAvailable(config, source));
|
|
60
|
+
const unavailable = requestedSources.filter((source) => !isSourceAvailable(config, source));
|
|
61
|
+
return {
|
|
62
|
+
requested: requestedSources,
|
|
63
|
+
sources,
|
|
64
|
+
unavailable,
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { defaultConfig, parsePluginConfig, resolveSources } from "./config";
|
|
3
|
+
import { noResultsResult, noSourcesResult, rawResultsResult, runSourceSearches, synthesizedResult, } from "./orchestrator";
|
|
4
|
+
import { DEPTHS, ResultSchema, SOURCE_IDS, } from "./schema";
|
|
5
|
+
import { synthesize } from "./synth";
|
|
6
|
+
const BRAND = "OpenSearch";
|
|
7
|
+
function serialize(result) {
|
|
8
|
+
return JSON.stringify(result, null, 2);
|
|
9
|
+
}
|
|
10
|
+
function previewQuery(query, max = 64) {
|
|
11
|
+
if (query.length <= max)
|
|
12
|
+
return query;
|
|
13
|
+
return `${query.slice(0, max - 1)}...`;
|
|
14
|
+
}
|
|
15
|
+
function describeSources(sources) {
|
|
16
|
+
if (sources.length === 0)
|
|
17
|
+
return "no sources";
|
|
18
|
+
if (sources.length === 1)
|
|
19
|
+
return `${sources[0]} only`;
|
|
20
|
+
if (sources.length === 2)
|
|
21
|
+
return `${sources[0]} + ${sources[1]}`;
|
|
22
|
+
return "session + web + code";
|
|
23
|
+
}
|
|
24
|
+
function runningTitle(sources) {
|
|
25
|
+
return `${BRAND} · searching ${describeSources(sources)}`;
|
|
26
|
+
}
|
|
27
|
+
function doneTitle(result) {
|
|
28
|
+
if (result.status === "no_sources")
|
|
29
|
+
return `${BRAND} · unavailable`;
|
|
30
|
+
if (result.status === "no_results")
|
|
31
|
+
return `${BRAND} · no matches`;
|
|
32
|
+
if (result.status === "raw") {
|
|
33
|
+
return `${BRAND} · ${result.meta.sources_yielded} raw result${result.meta.sources_yielded === 1 ? "" : "s"}`;
|
|
34
|
+
}
|
|
35
|
+
if (result.status === "raw_fallback")
|
|
36
|
+
return `${BRAND} · evidence fallback`;
|
|
37
|
+
return `${BRAND} · ${result.meta.sources_yielded} result${result.meta.sources_yielded === 1 ? "" : "s"}`;
|
|
38
|
+
}
|
|
39
|
+
function toolMetadata(input) {
|
|
40
|
+
return {
|
|
41
|
+
brand: BRAND,
|
|
42
|
+
phase: input.phase,
|
|
43
|
+
query: previewQuery(input.query),
|
|
44
|
+
depth: input.depth,
|
|
45
|
+
sources: input.sources,
|
|
46
|
+
source_summary: describeSources(input.sources),
|
|
47
|
+
...(input.result
|
|
48
|
+
? {
|
|
49
|
+
status: input.result.status,
|
|
50
|
+
answer: input.result.answer,
|
|
51
|
+
duration_ms: input.result.meta.duration,
|
|
52
|
+
sources_requested: input.result.meta.sources_requested,
|
|
53
|
+
sources_queried: input.result.meta.sources_queried,
|
|
54
|
+
sources_yielded: input.result.meta.sources_yielded,
|
|
55
|
+
source_errors: input.result.meta.source_errors.length,
|
|
56
|
+
sources_unavailable: input.result.meta.sources_unavailable,
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseResultOutput(output) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = ResultSchema.safeParse(JSON.parse(output));
|
|
64
|
+
return parsed.success ? parsed.data : undefined;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function parseRequestedSources(value) {
|
|
71
|
+
if (!Array.isArray(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
return value.filter((source) => typeof source === "string" && SOURCE_IDS.includes(source));
|
|
74
|
+
}
|
|
75
|
+
export const OpensearchPlugin = async (ctx) => {
|
|
76
|
+
let cfg = defaultConfig();
|
|
77
|
+
return {
|
|
78
|
+
async config(input) {
|
|
79
|
+
const next = parsePluginConfig(input);
|
|
80
|
+
if (next)
|
|
81
|
+
cfg = next;
|
|
82
|
+
},
|
|
83
|
+
"tool.definition": async (input, output) => {
|
|
84
|
+
if (input.toolID !== "opensearch")
|
|
85
|
+
return;
|
|
86
|
+
output.description =
|
|
87
|
+
"OpenSearch: use for broad investigation when the user asks to search, compare evidence, gather official docs, or combine session, web, and code results.";
|
|
88
|
+
},
|
|
89
|
+
"tool.execute.after": async (input, output) => {
|
|
90
|
+
if (input.tool !== "opensearch")
|
|
91
|
+
return;
|
|
92
|
+
const result = parseResultOutput(output.output);
|
|
93
|
+
if (!result)
|
|
94
|
+
return;
|
|
95
|
+
const requested = parseRequestedSources(input.args?.sources);
|
|
96
|
+
const resolved = resolveSources(cfg, requested);
|
|
97
|
+
output.title = doneTitle(result);
|
|
98
|
+
output.metadata = {
|
|
99
|
+
...(output.metadata ?? {}),
|
|
100
|
+
...toolMetadata({
|
|
101
|
+
phase: "completed",
|
|
102
|
+
query: result.meta.query,
|
|
103
|
+
depth: input.args && typeof input.args.depth === "string"
|
|
104
|
+
? input.args.depth
|
|
105
|
+
: cfg.depth,
|
|
106
|
+
sources: requested ?? resolved.sources,
|
|
107
|
+
result,
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
tool: {
|
|
112
|
+
opensearch: tool({
|
|
113
|
+
description: "Universal intelligent search. Queries session history, web, and code in parallel. Returns structured evidence-backed answer.",
|
|
114
|
+
args: {
|
|
115
|
+
query: tool.schema.string().describe("What to search for"),
|
|
116
|
+
sources: tool.schema
|
|
117
|
+
.array(tool.schema.enum(SOURCE_IDS))
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Sources to query. Defaults to all enabled."),
|
|
120
|
+
depth: tool.schema
|
|
121
|
+
.enum(DEPTHS)
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Search depth. Default: quick"),
|
|
124
|
+
},
|
|
125
|
+
async execute(args, context) {
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
const searchDepth = args.depth ?? cfg.depth;
|
|
128
|
+
const resolved = resolveSources(cfg, args.sources);
|
|
129
|
+
context.metadata({
|
|
130
|
+
title: runningTitle(resolved.sources),
|
|
131
|
+
metadata: toolMetadata({
|
|
132
|
+
phase: "searching",
|
|
133
|
+
query: args.query,
|
|
134
|
+
depth: searchDepth,
|
|
135
|
+
sources: resolved.sources,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
if (resolved.sources.length === 0) {
|
|
139
|
+
const result = noSourcesResult({
|
|
140
|
+
query: args.query,
|
|
141
|
+
start,
|
|
142
|
+
requested: resolved.requested,
|
|
143
|
+
unavailable: resolved.unavailable,
|
|
144
|
+
});
|
|
145
|
+
context.metadata({
|
|
146
|
+
title: doneTitle(result),
|
|
147
|
+
metadata: toolMetadata({
|
|
148
|
+
phase: "completed",
|
|
149
|
+
query: args.query,
|
|
150
|
+
depth: searchDepth,
|
|
151
|
+
sources: resolved.sources,
|
|
152
|
+
result,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
return serialize(result);
|
|
156
|
+
}
|
|
157
|
+
const search = await runSourceSearches({
|
|
158
|
+
client: ctx.client,
|
|
159
|
+
directory: context.directory,
|
|
160
|
+
config: cfg,
|
|
161
|
+
query: args.query,
|
|
162
|
+
depth: searchDepth,
|
|
163
|
+
sources: resolved.sources,
|
|
164
|
+
});
|
|
165
|
+
if (search.raw.length === 0) {
|
|
166
|
+
const result = noResultsResult({
|
|
167
|
+
query: args.query,
|
|
168
|
+
start,
|
|
169
|
+
requested: resolved.requested,
|
|
170
|
+
queried: resolved.sources,
|
|
171
|
+
unavailable: resolved.unavailable,
|
|
172
|
+
sourceErrors: search.sourceErrors,
|
|
173
|
+
});
|
|
174
|
+
context.metadata({
|
|
175
|
+
title: doneTitle(result),
|
|
176
|
+
metadata: toolMetadata({
|
|
177
|
+
phase: "completed",
|
|
178
|
+
query: args.query,
|
|
179
|
+
depth: searchDepth,
|
|
180
|
+
sources: resolved.sources,
|
|
181
|
+
result,
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
return serialize(result);
|
|
185
|
+
}
|
|
186
|
+
if (cfg.synth) {
|
|
187
|
+
context.metadata({
|
|
188
|
+
title: `${BRAND} · synthesizing evidence`,
|
|
189
|
+
metadata: {
|
|
190
|
+
...toolMetadata({
|
|
191
|
+
phase: "synthesizing",
|
|
192
|
+
query: args.query,
|
|
193
|
+
depth: searchDepth,
|
|
194
|
+
sources: resolved.sources,
|
|
195
|
+
}),
|
|
196
|
+
raw_results: search.raw.length,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
const synthesis = await synthesize(ctx.client, context.directory, search.raw, args.query);
|
|
201
|
+
const result = synthesizedResult({
|
|
202
|
+
query: args.query,
|
|
203
|
+
start,
|
|
204
|
+
requested: resolved.requested,
|
|
205
|
+
queried: resolved.sources,
|
|
206
|
+
unavailable: resolved.unavailable,
|
|
207
|
+
sourceErrors: search.sourceErrors,
|
|
208
|
+
raw: search.raw,
|
|
209
|
+
synthesis,
|
|
210
|
+
});
|
|
211
|
+
context.metadata({
|
|
212
|
+
title: doneTitle(result),
|
|
213
|
+
metadata: toolMetadata({
|
|
214
|
+
phase: "completed",
|
|
215
|
+
query: args.query,
|
|
216
|
+
depth: searchDepth,
|
|
217
|
+
sources: resolved.sources,
|
|
218
|
+
result,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
return serialize(result);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
const result = rawResultsResult({
|
|
225
|
+
query: args.query,
|
|
226
|
+
start,
|
|
227
|
+
requested: resolved.requested,
|
|
228
|
+
queried: resolved.sources,
|
|
229
|
+
unavailable: resolved.unavailable,
|
|
230
|
+
sourceErrors: search.sourceErrors,
|
|
231
|
+
raw: search.raw,
|
|
232
|
+
status: "raw_fallback",
|
|
233
|
+
});
|
|
234
|
+
context.metadata({
|
|
235
|
+
title: doneTitle(result),
|
|
236
|
+
metadata: {
|
|
237
|
+
...toolMetadata({
|
|
238
|
+
phase: "completed",
|
|
239
|
+
query: args.query,
|
|
240
|
+
depth: searchDepth,
|
|
241
|
+
sources: resolved.sources,
|
|
242
|
+
result,
|
|
243
|
+
}),
|
|
244
|
+
fallback: "synthesis_error",
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
return serialize(result);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const result = rawResultsResult({
|
|
251
|
+
query: args.query,
|
|
252
|
+
start,
|
|
253
|
+
requested: resolved.requested,
|
|
254
|
+
queried: resolved.sources,
|
|
255
|
+
unavailable: resolved.unavailable,
|
|
256
|
+
sourceErrors: search.sourceErrors,
|
|
257
|
+
raw: search.raw,
|
|
258
|
+
status: "raw",
|
|
259
|
+
});
|
|
260
|
+
context.metadata({
|
|
261
|
+
title: doneTitle(result),
|
|
262
|
+
metadata: toolMetadata({
|
|
263
|
+
phase: "completed",
|
|
264
|
+
query: args.query,
|
|
265
|
+
depth: searchDepth,
|
|
266
|
+
sources: resolved.sources,
|
|
267
|
+
result,
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
return serialize(result);
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
export default OpensearchPlugin;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { Config, RawResult, Result, Source, SourceError, SourceId, Synthesis } from "./schema";
|
|
3
|
+
export declare function normalize(raw: RawResult): Source;
|
|
4
|
+
export declare function runSourceSearches(input: {
|
|
5
|
+
client: ReturnType<typeof createOpencodeClient>;
|
|
6
|
+
directory: string;
|
|
7
|
+
config: Config;
|
|
8
|
+
query: string;
|
|
9
|
+
depth: Config["depth"];
|
|
10
|
+
sources: SourceId[];
|
|
11
|
+
}): Promise<{
|
|
12
|
+
raw: {
|
|
13
|
+
id: string;
|
|
14
|
+
type: "session" | "web" | "code";
|
|
15
|
+
title: string;
|
|
16
|
+
snippet: string;
|
|
17
|
+
relevance: number;
|
|
18
|
+
url?: string | undefined;
|
|
19
|
+
timestamp?: number | undefined;
|
|
20
|
+
}[];
|
|
21
|
+
sourceErrors: {
|
|
22
|
+
code: "unavailable" | "request_failed" | "invalid_response";
|
|
23
|
+
message: string;
|
|
24
|
+
source: "session" | "web" | "code";
|
|
25
|
+
}[];
|
|
26
|
+
}>;
|
|
27
|
+
export declare function noSourcesResult(input: {
|
|
28
|
+
query: string;
|
|
29
|
+
start: number;
|
|
30
|
+
requested: SourceId[];
|
|
31
|
+
unavailable: SourceId[];
|
|
32
|
+
}): Result;
|
|
33
|
+
export declare function noResultsResult(input: {
|
|
34
|
+
query: string;
|
|
35
|
+
start: number;
|
|
36
|
+
requested: SourceId[];
|
|
37
|
+
queried: SourceId[];
|
|
38
|
+
unavailable: SourceId[];
|
|
39
|
+
sourceErrors: SourceError[];
|
|
40
|
+
}): Result;
|
|
41
|
+
export declare function rawResultsResult(input: {
|
|
42
|
+
query: string;
|
|
43
|
+
start: number;
|
|
44
|
+
requested: SourceId[];
|
|
45
|
+
queried: SourceId[];
|
|
46
|
+
unavailable: SourceId[];
|
|
47
|
+
sourceErrors: SourceError[];
|
|
48
|
+
raw: RawResult[];
|
|
49
|
+
status: "raw" | "raw_fallback";
|
|
50
|
+
}): Result;
|
|
51
|
+
export declare function synthesizedResult(input: {
|
|
52
|
+
query: string;
|
|
53
|
+
start: number;
|
|
54
|
+
requested: SourceId[];
|
|
55
|
+
queried: SourceId[];
|
|
56
|
+
unavailable: SourceId[];
|
|
57
|
+
sourceErrors: SourceError[];
|
|
58
|
+
raw: RawResult[];
|
|
59
|
+
synthesis: Synthesis;
|
|
60
|
+
}): Result;
|