@slashfi/agents-sdk 0.79.0 → 0.80.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/dist/adk.js +90 -1
- package/dist/adk.js.map +1 -1
- package/dist/cjs/materialize.js +37 -10
- package/dist/cjs/materialize.js.map +1 -1
- package/dist/cjs/search.js +406 -0
- package/dist/cjs/search.js.map +1 -0
- package/dist/materialize.d.ts.map +1 -1
- package/dist/materialize.js +37 -10
- package/dist/materialize.js.map +1 -1
- package/dist/search.d.ts +185 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +396 -0
- package/dist/search.js.map +1 -0
- package/package.json +1 -1
- package/src/adk.ts +96 -1
- package/src/materialize.ts +48 -10
- package/src/search.test.ts +406 -0
- package/src/search.ts +541 -0
package/src/adk.ts
CHANGED
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
27
28
|
import { createAdk } from "./config-store.js";
|
|
28
29
|
import { createLocalFsStore, getLocalEncryptionKey } from "./local-fs.js";
|
|
29
30
|
import type { Adk } from "./config-store.js";
|
|
@@ -31,6 +32,12 @@ import { AdkError, getError, getRecentErrors } from "./adk-error.js";
|
|
|
31
32
|
import { runInit, parseTarget } from "./init.js";
|
|
32
33
|
import { materializeRef, syncAllRefs } from "./materialize.js";
|
|
33
34
|
import { adkCheck } from "./adk-check.js";
|
|
35
|
+
import {
|
|
36
|
+
refsRootExists,
|
|
37
|
+
renderResults,
|
|
38
|
+
searchRefs,
|
|
39
|
+
writeSearchIndex,
|
|
40
|
+
} from "./search.js";
|
|
34
41
|
|
|
35
42
|
const args = process.argv.slice(2);
|
|
36
43
|
const command = args[0];
|
|
@@ -39,6 +46,24 @@ const command = args[0];
|
|
|
39
46
|
// Helpers
|
|
40
47
|
// ============================================
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Read the SDK's published version from the sibling package.json.
|
|
51
|
+
* Resolved at runtime so a single source-of-truth lives in the manifest.
|
|
52
|
+
* Safe for both `bun src/adk.ts` (dev) and the npm-installed bin (which
|
|
53
|
+
* still runs through bun via the shebang).
|
|
54
|
+
*/
|
|
55
|
+
function getCliVersion(): string {
|
|
56
|
+
try {
|
|
57
|
+
const pkgPath = join(import.meta.dir, "..", "package.json");
|
|
58
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
59
|
+
version?: string;
|
|
60
|
+
};
|
|
61
|
+
return pkg.version ?? "unknown";
|
|
62
|
+
} catch {
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
function getArg(flag: string): string | undefined {
|
|
43
68
|
const idx = args.indexOf(flag);
|
|
44
69
|
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
@@ -95,10 +120,12 @@ adk — Agent Development Kit
|
|
|
95
120
|
Usage:
|
|
96
121
|
adk init [--target <agent>:<path>] Setup + install skills for coding agents
|
|
97
122
|
adk sync [--ref <name>] Materialize tool docs for all refs in config
|
|
123
|
+
adk search <query> [options] BM25 search over materialized refs/tools
|
|
98
124
|
adk registry <op> [options] Manage registry connections
|
|
99
125
|
adk ref <op> [options] Manage agent refs
|
|
100
126
|
adk config-path Print config directory path
|
|
101
127
|
adk error [id] View recent errors or a specific error
|
|
128
|
+
adk version | --version | -v Print the installed adk SDK version
|
|
102
129
|
|
|
103
130
|
Registry operations:
|
|
104
131
|
adk registry add <url> --name <name> [--auth-type bearer|api-key|none] [--proxy [--proxy-agent @config]]
|
|
@@ -135,6 +162,15 @@ Environment:
|
|
|
135
162
|
ADK_TOKEN Bearer token for authenticated registries
|
|
136
163
|
ADK_ENCRYPTION_KEY Override encryption key (default: auto from ~/.adk/.encryption-key)
|
|
137
164
|
|
|
165
|
+
Search options:
|
|
166
|
+
adk search <query> [--json] [--limit N] [--ref <name>] [--tools-only] [--refs-only]
|
|
167
|
+
BM25 over ~/.adk/refs/* — index includes
|
|
168
|
+
ref names, descriptions, tool names,
|
|
169
|
+
tool docs, parameter names, and skill
|
|
170
|
+
resources. Reads ~/.adk/.search-index.json
|
|
171
|
+
when present (rebuilt by \`adk sync\`),
|
|
172
|
+
otherwise walks refs/* on the fly.
|
|
173
|
+
|
|
138
174
|
Examples:
|
|
139
175
|
adk init --target claude --target cursor --target codex
|
|
140
176
|
adk registry add https://registry.slash.com --name public
|
|
@@ -142,6 +178,8 @@ Examples:
|
|
|
142
178
|
adk ref add notion --registry public
|
|
143
179
|
adk ref inspect notion --full
|
|
144
180
|
adk ref call notion notion-search '{"query":"hello"}'
|
|
181
|
+
adk search "schedule reminder" --json
|
|
182
|
+
adk search "email unread inbox" --tools-only --limit 5
|
|
145
183
|
`);
|
|
146
184
|
}
|
|
147
185
|
|
|
@@ -575,6 +613,58 @@ switch (command) {
|
|
|
575
613
|
for (const f of failed) console.log(` ${f.name}: ${f.error}`);
|
|
576
614
|
}
|
|
577
615
|
console.log(`\nDocs written to: ${configDir}/refs/`);
|
|
616
|
+
// Persist the BM25 search index so `adk search` can skip the
|
|
617
|
+
// recursive ref walk on every query. Best-effort — failure here
|
|
618
|
+
// shouldn't fail `adk sync` since the search path falls back to a
|
|
619
|
+
// fresh walk.
|
|
620
|
+
try {
|
|
621
|
+
const { path, documentCount } = writeSearchIndex(configDir);
|
|
622
|
+
console.log(`Search index: ${path} (${documentCount} docs)`);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
console.log(
|
|
625
|
+
`\x1b[33m!\x1b[0m Failed to write search index: ${err instanceof Error ? err.message : String(err)}`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case "search": {
|
|
631
|
+
const configDir = process.env.ADK_CONFIG_DIR ?? join(homedir(), ".adk");
|
|
632
|
+
const refsRoot = join(configDir, "refs");
|
|
633
|
+
// Positional query — first non-flag argv after the `search` command.
|
|
634
|
+
const query = args
|
|
635
|
+
.filter((a) => !a.startsWith("--"))
|
|
636
|
+
.slice(1)
|
|
637
|
+
.join(" ")
|
|
638
|
+
.trim();
|
|
639
|
+
if (!query) {
|
|
640
|
+
console.log("Usage: adk search \"<query>\" [--json] [--limit N] [--ref name] [--tools-only] [--refs-only]");
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
if (!refsRootExists(refsRoot)) {
|
|
644
|
+
const msg = `No materialized refs found at ${refsRoot}. Run \`adk sync\` first.`;
|
|
645
|
+
if (hasFlag("--json")) {
|
|
646
|
+
console.log(JSON.stringify({ error: msg, results: [] }));
|
|
647
|
+
} else {
|
|
648
|
+
console.log(msg);
|
|
649
|
+
}
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
const limitArg = getArg("--limit");
|
|
653
|
+
const limit = limitArg ? Number.parseInt(limitArg, 10) : undefined;
|
|
654
|
+
const ref = getArg("--ref");
|
|
655
|
+
const toolsOnly = hasFlag("--tools-only");
|
|
656
|
+
const refsOnly = hasFlag("--refs-only");
|
|
657
|
+
const results = searchRefs(refsRoot, query, {
|
|
658
|
+
...(limit !== undefined && Number.isFinite(limit) && { limit }),
|
|
659
|
+
...(ref && { ref }),
|
|
660
|
+
...(toolsOnly && { toolsOnly: true }),
|
|
661
|
+
...(refsOnly && { refsOnly: true }),
|
|
662
|
+
});
|
|
663
|
+
if (hasFlag("--json")) {
|
|
664
|
+
console.log(JSON.stringify(results, null, 2));
|
|
665
|
+
} else {
|
|
666
|
+
console.log(renderResults(results));
|
|
667
|
+
}
|
|
578
668
|
break;
|
|
579
669
|
}
|
|
580
670
|
case "config-path": {
|
|
@@ -619,6 +709,11 @@ switch (command) {
|
|
|
619
709
|
const result = await adkCheck({ file, code, run: isRun, noCheck });
|
|
620
710
|
process.exit(result.exitCode);
|
|
621
711
|
}
|
|
712
|
+
case "--version":
|
|
713
|
+
case "-v":
|
|
714
|
+
case "version":
|
|
715
|
+
console.log(getCliVersion());
|
|
716
|
+
break;
|
|
622
717
|
case "--help":
|
|
623
718
|
case "-h":
|
|
624
719
|
case undefined:
|
package/src/materialize.ts
CHANGED
|
@@ -288,20 +288,58 @@ export async function materializeRef(
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
// 2. Fetch and write resources (skills)
|
|
291
|
+
//
|
|
292
|
+
// `list_resources` returns URIs only — content is omitted to keep the
|
|
293
|
+
// listing payload small. We have to follow up with `read_resources(uris)`
|
|
294
|
+
// to actually fetch the body. Then the per-resource field is `content`,
|
|
295
|
+
// not `text` (per `CallAgentReadResourcesResponse`).
|
|
296
|
+
//
|
|
297
|
+
// Response shape varies depending on the call path: direct calls return
|
|
298
|
+
// `{success, agentPath, resources}` while proxied calls return
|
|
299
|
+
// `{success, result: {success, agentPath, resources}}` (the proxy wraps
|
|
300
|
+
// the inner registry response). Unwrap both shapes the same way.
|
|
291
301
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
302
|
+
type ResourceListEntry = {
|
|
303
|
+
uri?: string;
|
|
304
|
+
name?: string;
|
|
305
|
+
mimeType?: string;
|
|
306
|
+
};
|
|
307
|
+
type ResourceReadEntry = ResourceListEntry & {
|
|
308
|
+
content?: string;
|
|
309
|
+
error?: string;
|
|
310
|
+
};
|
|
311
|
+
const unwrapResources = <T>(raw: unknown): T[] => {
|
|
312
|
+
const r = raw as Record<string, unknown> | null | undefined;
|
|
313
|
+
if (!r) return [];
|
|
314
|
+
if (Array.isArray(r.resources)) return r.resources as T[];
|
|
315
|
+
const inner = r.result as Record<string, unknown> | undefined;
|
|
316
|
+
if (inner && Array.isArray(inner.resources))
|
|
317
|
+
return inner.resources as T[];
|
|
318
|
+
return [];
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const listed = unwrapResources<ResourceListEntry>(
|
|
322
|
+
await adk.ref.resources(refName),
|
|
323
|
+
);
|
|
324
|
+
const uris = listed
|
|
325
|
+
.map((r) => r.uri)
|
|
326
|
+
.filter((u): u is string => typeof u === "string" && u.length > 0);
|
|
327
|
+
|
|
328
|
+
if (uris.length > 0) {
|
|
329
|
+
const fetched = unwrapResources<ResourceReadEntry>(
|
|
330
|
+
await adk.ref.read(refName, uris),
|
|
331
|
+
);
|
|
332
|
+
for (const resource of fetched) {
|
|
333
|
+
if (!resource.uri) continue;
|
|
334
|
+
if (typeof resource.content !== "string") continue;
|
|
335
|
+
const filename = resource.uri.split("/").pop() || "resource.md";
|
|
336
|
+
ensureWrite(join(skillsDir, filename), resource.content);
|
|
337
|
+
skillCount++;
|
|
301
338
|
}
|
|
302
339
|
}
|
|
303
340
|
} catch {
|
|
304
|
-
// resources fetch failed — might not
|
|
341
|
+
// resources fetch failed — registry might not support resources, or
|
|
342
|
+
// ref isn't authenticated yet. Best-effort only.
|
|
305
343
|
}
|
|
306
344
|
|
|
307
345
|
return { toolCount, skillCount, typesGenerated, docsGenerated };
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
type PersistedSearchIndex,
|
|
14
|
+
buildSearchIndex,
|
|
15
|
+
readSearchIndex,
|
|
16
|
+
searchIndexPath,
|
|
17
|
+
searchRefs,
|
|
18
|
+
writeSearchIndex,
|
|
19
|
+
} from "./search";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a tiny on-disk fixture mimicking what `adk sync` would produce.
|
|
23
|
+
* Returns the `configDir` (root) and `refs/` root for `searchRefs`.
|
|
24
|
+
*/
|
|
25
|
+
function buildFixture(): {
|
|
26
|
+
configDir: string;
|
|
27
|
+
refsRoot: string;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
} {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), "adk-search-"));
|
|
31
|
+
const refsRoot = join(root, "refs");
|
|
32
|
+
mkdirSync(refsRoot, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const writeRef = (
|
|
35
|
+
relPath: string,
|
|
36
|
+
manifest: Record<string, unknown>,
|
|
37
|
+
entrypoint: string,
|
|
38
|
+
tools: Array<{ name: string; description: string; params?: string[] }>,
|
|
39
|
+
skills: Array<{ name: string; body: string }> = [],
|
|
40
|
+
) => {
|
|
41
|
+
const refDir = join(refsRoot, relPath);
|
|
42
|
+
mkdirSync(refDir, { recursive: true });
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(refDir, "agent.json"),
|
|
45
|
+
JSON.stringify({ ...manifest, toolCount: tools.length }, null, 2),
|
|
46
|
+
);
|
|
47
|
+
writeFileSync(join(refDir, "entrypoint.md"), entrypoint);
|
|
48
|
+
if (tools.length > 0) {
|
|
49
|
+
const toolsDir = join(refDir, "tools");
|
|
50
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
51
|
+
for (const tool of tools) {
|
|
52
|
+
const safe = tool.name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(toolsDir, `${safe}.tool.md`),
|
|
55
|
+
`# ${tool.name}\n\n${tool.description}\n`,
|
|
56
|
+
);
|
|
57
|
+
const properties: Record<string, { description: string }> = {};
|
|
58
|
+
for (const p of tool.params ?? []) {
|
|
59
|
+
properties[p] = { description: `${p} parameter` };
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(toolsDir, `${safe}.tool.json`),
|
|
63
|
+
JSON.stringify(
|
|
64
|
+
{
|
|
65
|
+
name: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
inputSchema: { type: "object", properties },
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (skills.length > 0) {
|
|
76
|
+
const skillsDir = join(refDir, "skills");
|
|
77
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
78
|
+
for (const skill of skills) {
|
|
79
|
+
writeFileSync(join(skillsDir, skill.name), skill.body);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Integration ref: notion (with a synced skill resource)
|
|
85
|
+
writeRef(
|
|
86
|
+
"notion",
|
|
87
|
+
{
|
|
88
|
+
name: "notion",
|
|
89
|
+
description:
|
|
90
|
+
"Interact with the Notion API — search, create, update pages and databases.",
|
|
91
|
+
},
|
|
92
|
+
"Notion entrypoint with database details and page editing examples.",
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
name: "notion-search",
|
|
96
|
+
description: "Search Notion pages and databases by query string.",
|
|
97
|
+
params: ["query", "filter"],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "notion-fetch",
|
|
101
|
+
description: "Fetch a Notion page by ID and return blocks.",
|
|
102
|
+
params: ["pageId"],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
{
|
|
107
|
+
name: "writing-pages.md",
|
|
108
|
+
body: "# Writing Notion Pages\n\nBest practices for structuring page hierarchies and using callout blocks.\n",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Integration ref: google-calendar
|
|
114
|
+
writeRef(
|
|
115
|
+
"google-calendar",
|
|
116
|
+
{
|
|
117
|
+
name: "google-calendar",
|
|
118
|
+
description:
|
|
119
|
+
"View and manage Google Calendar events, calendars, and availability.",
|
|
120
|
+
},
|
|
121
|
+
"Google Calendar lets you list, create, and update events.",
|
|
122
|
+
[
|
|
123
|
+
{
|
|
124
|
+
name: "list_events",
|
|
125
|
+
description:
|
|
126
|
+
"List events in a calendar within a time window. Supports calendar ID and timezone filters.",
|
|
127
|
+
params: ["calendar_id", "time_min", "time_max"],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "create_event",
|
|
131
|
+
description: "Create a new event with title, start, and end.",
|
|
132
|
+
params: ["calendar_id", "summary", "start", "end"],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Platform agent: /agents/@clock — nested under refs/agents/@clock
|
|
138
|
+
writeRef(
|
|
139
|
+
"agents/@clock",
|
|
140
|
+
{
|
|
141
|
+
name: "/agents/@clock",
|
|
142
|
+
description:
|
|
143
|
+
"Time-based scheduling: timers, intervals, and current time queries.",
|
|
144
|
+
},
|
|
145
|
+
"Clock agent for scheduling reminders and time queries.",
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
name: "timer",
|
|
149
|
+
description:
|
|
150
|
+
"Schedule a one-shot reminder that fires at a specified time.",
|
|
151
|
+
params: ["fire_at", "payload"],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "time",
|
|
155
|
+
description: "Get the current time in a specified timezone.",
|
|
156
|
+
params: ["timezone"],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
configDir: root,
|
|
163
|
+
refsRoot,
|
|
164
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("adk search", () => {
|
|
169
|
+
let fixture: { configDir: string; refsRoot: string; cleanup: () => void };
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
fixture = buildFixture();
|
|
172
|
+
});
|
|
173
|
+
afterEach(() => {
|
|
174
|
+
fixture.cleanup();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("tool name match ranks the right tool first", () => {
|
|
178
|
+
const results = searchRefs(fixture.refsRoot, "notion-search", { limit: 5 });
|
|
179
|
+
expect(results.length).toBeGreaterThan(0);
|
|
180
|
+
const top = results[0];
|
|
181
|
+
expect(top.kind).toBe("tool");
|
|
182
|
+
if (top.kind === "tool") {
|
|
183
|
+
expect(top.tool).toBe("notion-search");
|
|
184
|
+
expect(top.ref).toBe("notion");
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("description-only query finds the right tool", () => {
|
|
189
|
+
const results = searchRefs(fixture.refsRoot, "schedule reminder timer", {
|
|
190
|
+
limit: 5,
|
|
191
|
+
});
|
|
192
|
+
const tools = results.filter((r) => r.kind === "tool");
|
|
193
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
194
|
+
expect(tools[0]).toMatchObject({ ref: "/agents/@clock", tool: "timer" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("--ref restricts results to one ref (bare name)", () => {
|
|
198
|
+
const results = searchRefs(fixture.refsRoot, "search create update", {
|
|
199
|
+
ref: "notion",
|
|
200
|
+
limit: 10,
|
|
201
|
+
});
|
|
202
|
+
for (const r of results) {
|
|
203
|
+
expect(r.ref).toBe("notion");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("--ref accepts platform-agent path or shorthand", () => {
|
|
208
|
+
const fullPath = searchRefs(fixture.refsRoot, "timer", {
|
|
209
|
+
ref: "/agents/@clock",
|
|
210
|
+
limit: 5,
|
|
211
|
+
});
|
|
212
|
+
const shorthand = searchRefs(fixture.refsRoot, "timer", {
|
|
213
|
+
ref: "@clock",
|
|
214
|
+
limit: 5,
|
|
215
|
+
});
|
|
216
|
+
expect(fullPath.length).toBeGreaterThan(0);
|
|
217
|
+
expect(shorthand.length).toBeGreaterThan(0);
|
|
218
|
+
for (const r of [...fullPath, ...shorthand]) {
|
|
219
|
+
expect(r.ref).toBe("/agents/@clock");
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("--tools-only excludes ref-level results", () => {
|
|
224
|
+
const results = searchRefs(fixture.refsRoot, "notion", {
|
|
225
|
+
toolsOnly: true,
|
|
226
|
+
limit: 10,
|
|
227
|
+
});
|
|
228
|
+
expect(results.length).toBeGreaterThan(0);
|
|
229
|
+
for (const r of results) {
|
|
230
|
+
expect(r.kind).toBe("tool");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("--refs-only excludes tool-level results", () => {
|
|
235
|
+
const results = searchRefs(fixture.refsRoot, "notion", {
|
|
236
|
+
refsOnly: true,
|
|
237
|
+
limit: 10,
|
|
238
|
+
});
|
|
239
|
+
expect(results.length).toBeGreaterThan(0);
|
|
240
|
+
for (const r of results) {
|
|
241
|
+
expect(r.kind).toBe("ref");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("tool result includes docs path, schema path, and call snippet", () => {
|
|
246
|
+
const results = searchRefs(fixture.refsRoot, "list events", { limit: 3 });
|
|
247
|
+
const tool = results.find((r) => r.kind === "tool");
|
|
248
|
+
expect(tool).toBeDefined();
|
|
249
|
+
if (tool && tool.kind === "tool") {
|
|
250
|
+
expect(tool.docs).toContain("google-calendar/tools/");
|
|
251
|
+
expect(tool.docs.endsWith(".tool.md")).toBe(true);
|
|
252
|
+
expect(tool.schema.endsWith(".tool.json")).toBe(true);
|
|
253
|
+
expect(tool.call).toBe(
|
|
254
|
+
`adk ref call google-calendar ${tool.tool} '{...}'`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("parameter names contribute to the index", () => {
|
|
260
|
+
// 'fire_at' is a parameter of the @clock.timer tool.
|
|
261
|
+
const results = searchRefs(fixture.refsRoot, "fire_at", { limit: 5 });
|
|
262
|
+
const top = results.find((r) => r.kind === "tool");
|
|
263
|
+
expect(top).toBeDefined();
|
|
264
|
+
if (top && top.kind === "tool") {
|
|
265
|
+
expect(top.ref).toBe("/agents/@clock");
|
|
266
|
+
expect(top.tool).toBe("timer");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("empty results when no docs match", () => {
|
|
271
|
+
const results = searchRefs(
|
|
272
|
+
fixture.refsRoot,
|
|
273
|
+
"xxxxxxxxxxxxxxxxxxx-no-match",
|
|
274
|
+
{ limit: 5 },
|
|
275
|
+
);
|
|
276
|
+
expect(results).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("skill resources are indexed and surface as resource results", () => {
|
|
280
|
+
// The notion fixture has skills/writing-pages.md describing
|
|
281
|
+
// page hierarchies and callout blocks.
|
|
282
|
+
const results = searchRefs(fixture.refsRoot, "callout blocks hierarchy", {
|
|
283
|
+
limit: 10,
|
|
284
|
+
});
|
|
285
|
+
const resource = results.find((r) => r.kind === "resource");
|
|
286
|
+
expect(resource).toBeDefined();
|
|
287
|
+
if (resource && resource.kind === "resource") {
|
|
288
|
+
expect(resource.ref).toBe("notion");
|
|
289
|
+
expect(resource.resource).toBe("writing-pages.md");
|
|
290
|
+
expect(resource.docs).toContain("notion/skills/writing-pages.md");
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("--refs-only and --tools-only both exclude resource results", () => {
|
|
295
|
+
const refsOnly = searchRefs(fixture.refsRoot, "callout blocks", {
|
|
296
|
+
refsOnly: true,
|
|
297
|
+
limit: 10,
|
|
298
|
+
});
|
|
299
|
+
const toolsOnly = searchRefs(fixture.refsRoot, "callout blocks", {
|
|
300
|
+
toolsOnly: true,
|
|
301
|
+
limit: 10,
|
|
302
|
+
});
|
|
303
|
+
for (const r of [...refsOnly, ...toolsOnly]) {
|
|
304
|
+
expect(r.kind).not.toBe("resource");
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("writeSearchIndex persists a JSON file with docs + items", () => {
|
|
309
|
+
const { path, documentCount } = writeSearchIndex(fixture.configDir);
|
|
310
|
+
expect(existsSync(path)).toBe(true);
|
|
311
|
+
expect(path).toBe(searchIndexPath(fixture.configDir));
|
|
312
|
+
// Dot-prefixed so coding agents skip it.
|
|
313
|
+
expect(path.endsWith("/.search-index.json")).toBe(true);
|
|
314
|
+
expect(documentCount).toBeGreaterThan(0);
|
|
315
|
+
|
|
316
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as PersistedSearchIndex;
|
|
317
|
+
expect(raw.version).toBe(1);
|
|
318
|
+
expect(Array.isArray(raw.docs)).toBe(true);
|
|
319
|
+
expect(raw.docs.length).toBe(documentCount);
|
|
320
|
+
// Every doc id should have a matching item entry.
|
|
321
|
+
for (const doc of raw.docs) {
|
|
322
|
+
expect(raw.items[doc.id]).toBeDefined();
|
|
323
|
+
}
|
|
324
|
+
// At least one of each kind should be present.
|
|
325
|
+
const kinds = new Set(Object.values(raw.items).map((i) => i.kind));
|
|
326
|
+
expect(kinds.has("ref")).toBe(true);
|
|
327
|
+
expect(kinds.has("tool")).toBe(true);
|
|
328
|
+
expect(kinds.has("resource")).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("searchRefs uses the persisted index when present", () => {
|
|
332
|
+
writeSearchIndex(fixture.configDir);
|
|
333
|
+
// Drop a stub doc into the persisted index so we can prove the
|
|
334
|
+
// search path read it instead of walking the filesystem.
|
|
335
|
+
const persisted = readSearchIndex(fixture.configDir);
|
|
336
|
+
expect(persisted).not.toBeNull();
|
|
337
|
+
if (!persisted) return;
|
|
338
|
+
const stubId = "tool:stub-ref|stub-only-tool";
|
|
339
|
+
persisted.docs.push({
|
|
340
|
+
id: stubId,
|
|
341
|
+
text: "stub-only-tool stub-ref unique-marker xyzzy",
|
|
342
|
+
});
|
|
343
|
+
persisted.items[stubId] = {
|
|
344
|
+
kind: "tool",
|
|
345
|
+
ref: "stub-ref",
|
|
346
|
+
tool: "stub-only-tool",
|
|
347
|
+
summary: "Stub tool that only exists in the persisted index.",
|
|
348
|
+
docs: "/persisted/stub.tool.md",
|
|
349
|
+
schema: "/persisted/stub.tool.json",
|
|
350
|
+
call: "adk ref call stub-ref stub-only-tool '{...}'",
|
|
351
|
+
};
|
|
352
|
+
writeFileSync(
|
|
353
|
+
searchIndexPath(fixture.configDir),
|
|
354
|
+
JSON.stringify(persisted, null, 2),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const results = searchRefs(fixture.refsRoot, "xyzzy unique-marker", {
|
|
358
|
+
limit: 5,
|
|
359
|
+
});
|
|
360
|
+
expect(results.length).toBeGreaterThan(0);
|
|
361
|
+
const stub = results.find(
|
|
362
|
+
(r) => r.kind === "tool" && r.tool === "stub-only-tool",
|
|
363
|
+
);
|
|
364
|
+
expect(stub).toBeDefined();
|
|
365
|
+
// And the stub doesn't exist on disk under refs/, proving the
|
|
366
|
+
// search read from the persisted index, not the filesystem.
|
|
367
|
+
expect(existsSync(join(fixture.refsRoot, "stub-ref"))).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("searchRefs falls back to fresh walk when persisted file is missing", () => {
|
|
371
|
+
expect(existsSync(searchIndexPath(fixture.configDir))).toBe(false);
|
|
372
|
+
const results = searchRefs(fixture.refsRoot, "notion-search", {
|
|
373
|
+
limit: 3,
|
|
374
|
+
});
|
|
375
|
+
expect(results.length).toBeGreaterThan(0);
|
|
376
|
+
expect(results[0].kind).toBe("tool");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("buildSearchIndex returns docs + items consistent with each other", () => {
|
|
380
|
+
const index = buildSearchIndex(fixture.refsRoot);
|
|
381
|
+
expect(index.version).toBe(1);
|
|
382
|
+
expect(index.docs.length).toBeGreaterThan(0);
|
|
383
|
+
for (const doc of index.docs) {
|
|
384
|
+
const item = index.items[doc.id];
|
|
385
|
+
expect(item).toBeDefined();
|
|
386
|
+
// Id encodes the kind.
|
|
387
|
+
const expectedKind = doc.id.startsWith("ref:")
|
|
388
|
+
? "ref"
|
|
389
|
+
: doc.id.startsWith("tool:")
|
|
390
|
+
? "tool"
|
|
391
|
+
: "resource";
|
|
392
|
+
expect(item.kind).toBe(expectedKind);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("readSearchIndex returns null for missing or version-mismatched files", () => {
|
|
397
|
+
expect(readSearchIndex(fixture.configDir)).toBeNull();
|
|
398
|
+
// Write a bad-version file and confirm we ignore it.
|
|
399
|
+
mkdirSync(fixture.configDir, { recursive: true });
|
|
400
|
+
writeFileSync(
|
|
401
|
+
searchIndexPath(fixture.configDir),
|
|
402
|
+
JSON.stringify({ version: 99, docs: [], items: {} }),
|
|
403
|
+
);
|
|
404
|
+
expect(readSearchIndex(fixture.configDir)).toBeNull();
|
|
405
|
+
});
|
|
406
|
+
});
|