@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { failure, messageFromError } from "./shared";
|
|
2
|
+
export async function searchCode(query, depth) {
|
|
3
|
+
const source = "code";
|
|
4
|
+
try {
|
|
5
|
+
const url = `https://grep.app/api/search?q=${encodeURIComponent(query)}&limit=${depth === "quick" ? 5 : 10}`;
|
|
6
|
+
const res = await fetch(url);
|
|
7
|
+
if (!res.ok) {
|
|
8
|
+
return failure(source, "request_failed", `grep.app search failed with status ${res.status}.`);
|
|
9
|
+
}
|
|
10
|
+
const body = (await res.json());
|
|
11
|
+
if (body.hits && !Array.isArray(body.hits.hits)) {
|
|
12
|
+
return failure(source, "invalid_response", "grep.app search returned an invalid payload.");
|
|
13
|
+
}
|
|
14
|
+
const list = body.hits?.hits ?? [];
|
|
15
|
+
return {
|
|
16
|
+
source,
|
|
17
|
+
results: list.map((hit, i) => {
|
|
18
|
+
const repo = hit.repo?.raw ?? "unknown";
|
|
19
|
+
const path = hit.path?.raw ?? "";
|
|
20
|
+
return {
|
|
21
|
+
id: `code-${repo}-${path}-${i}`,
|
|
22
|
+
type: source,
|
|
23
|
+
title: path ? `${repo}/${path}` : repo,
|
|
24
|
+
snippet: (hit.content?.snippet ?? "").slice(0, 700),
|
|
25
|
+
url: `https://grep.app/search?q=${encodeURIComponent(query)}`,
|
|
26
|
+
relevance: typeof hit.score === "number"
|
|
27
|
+
? Math.min(1, Math.max(0, hit.score / 100))
|
|
28
|
+
: 0.5,
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return failure(source, "request_failed", `grep.app search failed: ${messageFromError(error)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { Depth } from "../schema";
|
|
3
|
+
import { type SourceSearchOutcome } from "./shared";
|
|
4
|
+
export declare function searchSessions(client: ReturnType<typeof createOpencodeClient>, directory: string, query: string, depth: Depth): Promise<SourceSearchOutcome>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { failure, messageFromError, sourceError } from "./shared";
|
|
2
|
+
function words(query) {
|
|
3
|
+
return query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
4
|
+
}
|
|
5
|
+
function count(text, key) {
|
|
6
|
+
return text.toLowerCase().split(key.toLowerCase()).length - 1;
|
|
7
|
+
}
|
|
8
|
+
function text(parts) {
|
|
9
|
+
return parts
|
|
10
|
+
.filter((part) => part.type === "text" || part.type === "reasoning")
|
|
11
|
+
.map((part) => part.text ?? "")
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
14
|
+
export async function searchSessions(client, directory, query, depth) {
|
|
15
|
+
const source = "session";
|
|
16
|
+
const key = words(query);
|
|
17
|
+
if (key.length === 0) {
|
|
18
|
+
return {
|
|
19
|
+
source,
|
|
20
|
+
results: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
let listRes;
|
|
24
|
+
try {
|
|
25
|
+
listRes = await client.session.list({ query: { directory } });
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return failure(source, "request_failed", `Session search failed: ${messageFromError(error)}`);
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(listRes?.data)) {
|
|
31
|
+
return failure(source, "invalid_response", "Session search returned an invalid session list.");
|
|
32
|
+
}
|
|
33
|
+
const list = listRes.data;
|
|
34
|
+
const top = list
|
|
35
|
+
.filter((item) => key.some((word) => item.title.toLowerCase().includes(word)))
|
|
36
|
+
.slice(0, depth === "quick" ? 5 : 15);
|
|
37
|
+
const settled = await Promise.allSettled(top.map(async (item) => {
|
|
38
|
+
const msgRes = await client.session.messages({
|
|
39
|
+
path: { id: item.id },
|
|
40
|
+
query: { directory, limit: depth === "quick" ? 50 : 200 },
|
|
41
|
+
});
|
|
42
|
+
if (!Array.isArray(msgRes.data)) {
|
|
43
|
+
throw new Error("invalid session transcript payload");
|
|
44
|
+
}
|
|
45
|
+
const body = msgRes.data.map((row) => text(row.parts)).join("\n");
|
|
46
|
+
const hit = key.reduce((sum, word) => sum + count(body, word), 0);
|
|
47
|
+
const rel = Math.min(1, hit / Math.max(1, key.length * 4));
|
|
48
|
+
if (hit === 0)
|
|
49
|
+
return null;
|
|
50
|
+
return {
|
|
51
|
+
id: item.id,
|
|
52
|
+
type: source,
|
|
53
|
+
title: item.title,
|
|
54
|
+
snippet: body.slice(0, 700) || item.title,
|
|
55
|
+
url: item.id,
|
|
56
|
+
relevance: rel,
|
|
57
|
+
timestamp: item.time.updated,
|
|
58
|
+
};
|
|
59
|
+
}));
|
|
60
|
+
const results = settled.flatMap((item) => {
|
|
61
|
+
if (item.status !== "fulfilled" || item.value === null)
|
|
62
|
+
return [];
|
|
63
|
+
return [item.value];
|
|
64
|
+
});
|
|
65
|
+
const failed = settled.filter((item) => item.status === "rejected").length;
|
|
66
|
+
return {
|
|
67
|
+
source,
|
|
68
|
+
results,
|
|
69
|
+
...(failed > 0
|
|
70
|
+
? {
|
|
71
|
+
error: sourceError(source, "request_failed", `Failed to read ${failed} session ${failed === 1 ? "transcript" : "transcripts"}.`),
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RawResult, SourceError, SourceId } from "../schema";
|
|
2
|
+
export type SourceSearchOutcome = {
|
|
3
|
+
source: SourceId;
|
|
4
|
+
results: RawResult[];
|
|
5
|
+
error?: SourceError;
|
|
6
|
+
};
|
|
7
|
+
export declare function sourceError(source: SourceId, code: SourceError["code"], message: string): SourceError;
|
|
8
|
+
export declare function failure(source: SourceId, code: SourceError["code"], message: string): SourceSearchOutcome;
|
|
9
|
+
export declare function messageFromError(error: unknown): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function sourceError(source, code, message) {
|
|
2
|
+
return {
|
|
3
|
+
source,
|
|
4
|
+
code,
|
|
5
|
+
message,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function failure(source, code, message) {
|
|
9
|
+
return {
|
|
10
|
+
source,
|
|
11
|
+
results: [],
|
|
12
|
+
error: sourceError(source, code, message),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function messageFromError(error) {
|
|
16
|
+
if (error instanceof Error && error.message)
|
|
17
|
+
return error.message;
|
|
18
|
+
return String(error);
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { failure, messageFromError } from "./shared";
|
|
2
|
+
export async function searchWeb(query, key, depth) {
|
|
3
|
+
const source = "web";
|
|
4
|
+
if (!key) {
|
|
5
|
+
return failure(source, "unavailable", "Web source requires OPENSEARCH_WEB_KEY or EXA_API_KEY.");
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch("https://api.exa.ai/search", {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: {
|
|
11
|
+
"x-api-key": key,
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
query,
|
|
16
|
+
numResults: depth === "quick" ? 5 : 10,
|
|
17
|
+
type: "auto",
|
|
18
|
+
contents: {
|
|
19
|
+
text: {
|
|
20
|
+
maxCharacters: 1000,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
return failure(source, "request_failed", `Exa search failed with status ${res.status}.`);
|
|
27
|
+
}
|
|
28
|
+
const body = (await res.json());
|
|
29
|
+
if (!Array.isArray(body.results)) {
|
|
30
|
+
return failure(source, "invalid_response", "Exa search returned an invalid payload.");
|
|
31
|
+
}
|
|
32
|
+
const list = body.results;
|
|
33
|
+
return {
|
|
34
|
+
source,
|
|
35
|
+
results: list.map((item, i) => ({
|
|
36
|
+
id: item.id ?? `web-${i}`,
|
|
37
|
+
type: source,
|
|
38
|
+
title: item.title ?? item.url ?? "Untitled",
|
|
39
|
+
snippet: (item.text ?? "").slice(0, 700),
|
|
40
|
+
url: item.url,
|
|
41
|
+
relevance: typeof item.score === "number"
|
|
42
|
+
? Math.min(1, Math.max(0, item.score))
|
|
43
|
+
: 0.5,
|
|
44
|
+
timestamp: item.publishedDate
|
|
45
|
+
? Date.parse(item.publishedDate) || undefined
|
|
46
|
+
: undefined,
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return failure(source, "request_failed", `Exa search failed: ${messageFromError(error)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/synth.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { type RawResult, type Synthesis } from "./schema";
|
|
3
|
+
export declare function synthesize(client: ReturnType<typeof createOpencodeClient>, directory: string, raw: RawResult[], query: string): Promise<Synthesis>;
|
package/dist/synth.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { SynthesisSchema, synthesisJsonSchema, } from "./schema";
|
|
2
|
+
const PROMPT = `You are a synthesis engine for opensearch.
|
|
3
|
+
|
|
4
|
+
Goal:
|
|
5
|
+
- Produce a laconic direct answer to the user query.
|
|
6
|
+
- Every factual claim must map to source IDs from the provided sources.
|
|
7
|
+
- Set confidence by source agreement and evidence quality.
|
|
8
|
+
- Suggest 2-3 concise followup queries.
|
|
9
|
+
|
|
10
|
+
Output rules:
|
|
11
|
+
- Return valid JSON only.
|
|
12
|
+
- Use source IDs exactly as provided.
|
|
13
|
+
- Do not invent sources.
|
|
14
|
+
- Keep answer compact and concrete.`;
|
|
15
|
+
function text(parts) {
|
|
16
|
+
return parts
|
|
17
|
+
.filter((part) => part.type === "text" || part.type === "reasoning")
|
|
18
|
+
.map((part) => part.text ?? "")
|
|
19
|
+
.join("\n");
|
|
20
|
+
}
|
|
21
|
+
function parse(input) {
|
|
22
|
+
const body = input.trim();
|
|
23
|
+
if (!body)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(body);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
const start = body.indexOf("{");
|
|
30
|
+
const end = body.lastIndexOf("}");
|
|
31
|
+
if (start < 0 || end <= start)
|
|
32
|
+
return null;
|
|
33
|
+
return JSON.parse(body.slice(start, end + 1));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function synthesize(client, directory, raw, query) {
|
|
37
|
+
const made = await client.session.create({
|
|
38
|
+
query: { directory },
|
|
39
|
+
body: { title: "opensearch-synth" },
|
|
40
|
+
});
|
|
41
|
+
const id = made.data?.id;
|
|
42
|
+
if (!id) {
|
|
43
|
+
throw new Error("Unable to create a synthesis session.");
|
|
44
|
+
}
|
|
45
|
+
const input = `${PROMPT}\n\nUser query:\n${query}\n\nSources:\n${raw
|
|
46
|
+
.map((item, i) => `${i + 1}. id=${item.id} type=${item.type} title=${item.title}\nurl=${item.url ?? ""}\nrelevance=${item.relevance}\nsnippet=${item.snippet}`)
|
|
47
|
+
.join("\n\n")}`;
|
|
48
|
+
try {
|
|
49
|
+
const req = {
|
|
50
|
+
path: { id },
|
|
51
|
+
query: { directory },
|
|
52
|
+
body: {
|
|
53
|
+
agent: undefined,
|
|
54
|
+
noReply: false,
|
|
55
|
+
parts: [{ type: "text", text: input }],
|
|
56
|
+
format: {
|
|
57
|
+
type: "json_schema",
|
|
58
|
+
schema: synthesisJsonSchema(),
|
|
59
|
+
retryCount: 2,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const msg = await client.session.prompt(req);
|
|
64
|
+
const body = parse(text(msg.data?.parts ?? []));
|
|
65
|
+
if (!body) {
|
|
66
|
+
throw new Error("Synthesis returned no JSON output.");
|
|
67
|
+
}
|
|
68
|
+
const parsed = SynthesisSchema.safeParse(body);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
throw new Error("Synthesis returned an invalid payload.");
|
|
71
|
+
}
|
|
72
|
+
return parsed.data;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await client.session
|
|
76
|
+
.delete({ path: { id }, query: { directory } })
|
|
77
|
+
.catch(() => true);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kagan-sh/opensearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that combines session, web, and code search",
|
|
5
|
+
"homepage": "https://kagan-sh.github.io/opensearch/",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kagan-sh/opensearch.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/kagan-sh/opensearch/issues"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CONTRIBUTING.md",
|
|
27
|
+
"SKILL.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "bun tsc",
|
|
32
|
+
"typecheck": "bun tsc --noEmit",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"docs:build": "mkdocs build --strict",
|
|
36
|
+
"docs:serve": "mkdocs serve",
|
|
37
|
+
"check": "bun run typecheck && bun run test && bun run build",
|
|
38
|
+
"release": "semantic-release",
|
|
39
|
+
"release:dry-run": "semantic-release --dry-run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@opencode-ai/plugin": "1.2.27",
|
|
43
|
+
"@opencode-ai/sdk": "1.2.27",
|
|
44
|
+
"zod": "^3.24.0",
|
|
45
|
+
"zod-to-json-schema": "^3.24.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "1.3.11",
|
|
49
|
+
"conventional-changelog-conventionalcommits": "9.3.0",
|
|
50
|
+
"semantic-release": "25.0.3",
|
|
51
|
+
"typescript": "5.9.3",
|
|
52
|
+
"vitest": "4.1.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"bun": ">=1.2.0"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"registry": "https://registry.npmjs.org/",
|
|
60
|
+
"provenance": true
|
|
61
|
+
}
|
|
62
|
+
}
|