@frockbot/plugin-search 0.0.0 → 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/frockbot.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "id": "search",
4
+ "displayName": "Search",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "flock": ">=0.0.1",
9
+ "shell": ">=0.0.1",
10
+ "ui-theme": ">=0.0.1"
11
+ },
12
+ "contributions": {
13
+ "backend": [
14
+ { "entry": "./user", "host": "user" },
15
+ { "entry": "./backend", "host": "gateway" }
16
+ ],
17
+ "client": {
18
+ "entry": "./client",
19
+ "mounts": [{ "slot": "frockbot.header-actions", "order": 10 }],
20
+ "outlets": []
21
+ }
22
+ },
23
+ "permissions": []
24
+ }
package/package.json CHANGED
@@ -1,14 +1,50 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-search",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./backend": "./src/backend.ts",
9
+ "./bot": "./src/bot.ts",
10
+ "./client": "./src/client/index.ts",
11
+ "./frockbot.json": "./frockbot.json",
12
+ "./groups": "./src/groups.ts",
13
+ "./index-store": "./src/index-store.ts",
14
+ "./manifest": "./src/manifest.ts",
15
+ "./package.json": "./package.json",
16
+ "./shared": "./src/shared.ts",
17
+ "./testing": "./src/testing.ts",
18
+ "./user": "./src/user.ts"
19
+ },
20
+ "frockbot": {
21
+ "manifest": "./frockbot.json"
22
+ },
23
+ "scripts": {
24
+ "test": "bun test src",
25
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
26
+ },
27
+ "dependencies": {
28
+ "@frockbot/client-core": "0.1.0",
29
+ "@frockbot/client-ui": "0.1.0",
30
+ "@frockbot/plugin-flock": "0.1.0",
31
+ "@frockbot/plugin-shell": "0.1.0",
32
+ "cordis": "4.0.0-rc.8",
33
+ "vue": "3.5.41"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "1.4.0",
37
+ "@vitejs/plugin-vue": "6.0.8",
38
+ "typescript": "5.9.3",
39
+ "vite": "8.2.2",
40
+ "vue-tsc": "3.3.10"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
6
45
  "repository": {
7
46
  "type": "git",
8
47
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
48
  "directory": "packages/plugin-search"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
49
  }
14
50
  }
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createSearchBackendContribution,
4
+ decodeSearchRequestQueryV1,
5
+ type SearchGatewayHost,
6
+ } from "./backend.ts";
7
+ import {
8
+ decodeClientSearchRebuildReceiptV1,
9
+ decodeClientSearchResultsV1,
10
+ type SearchIndexResultsV1,
11
+ type SearchQueryV1,
12
+ } from "./shared.ts";
13
+
14
+ function url(search: string): URL {
15
+ return new URL(`https://frockbot.test/api/search${search}`);
16
+ }
17
+
18
+ function host(
19
+ overrides: Partial<SearchGatewayHost> = {},
20
+ ): SearchGatewayHost & { queries: SearchQueryV1[] } {
21
+ const queries: SearchQueryV1[] = [];
22
+ return {
23
+ queries,
24
+ async searchTranscripts(_userId, query) {
25
+ queries.push(query);
26
+ return {
27
+ schemaVersion: 1,
28
+ query: query.query,
29
+ hits: [
30
+ {
31
+ botId: "bot-a",
32
+ runId: "run-1",
33
+ kind: "user",
34
+ at: "2026-08-31T00:00:00.000Z",
35
+ snippet: "the gym build",
36
+ },
37
+ ],
38
+ truncated: false,
39
+ indexState: "ready",
40
+ } satisfies SearchIndexResultsV1;
41
+ },
42
+ async rebuildSearchIndex() {
43
+ return {
44
+ schemaVersion: 1,
45
+ status: "rebuilt",
46
+ indexedRows: 4,
47
+ bots: 2,
48
+ indexState: "ready",
49
+ };
50
+ },
51
+ async listBotIdentities() {
52
+ return {
53
+ schemaVersion: 1,
54
+ identities: [
55
+ {
56
+ schemaVersion: 1,
57
+ botId: "bot-a",
58
+ name: "Site foreman",
59
+ namedBy: "user",
60
+ hiddenFromSidebar: false,
61
+ },
62
+ ],
63
+ };
64
+ },
65
+ async listBotLifecycles() {
66
+ return { schemaVersion: 1, lifecycles: [] };
67
+ },
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ const CONTEXT = { userId: "user-1", client: "browser" as const };
73
+
74
+ describe("the search query string", () => {
75
+ test("round-trips every accepted parameter into the exact DTO", () => {
76
+ expect(
77
+ decodeSearchRequestQueryV1(
78
+ url(
79
+ "?q=gym&before=p50&kinds=user,tool&botId=bot-a&includeArchived=true",
80
+ ),
81
+ ),
82
+ ).toEqual({
83
+ schemaVersion: 1,
84
+ query: "gym",
85
+ before: "p50",
86
+ kinds: ["user", "tool"],
87
+ botId: "bot-a",
88
+ includeArchived: true,
89
+ });
90
+ });
91
+
92
+ test("refuses an unexpected, repeated, or invalid parameter", () => {
93
+ expect(() =>
94
+ decodeSearchRequestQueryV1(url("?q=gym&userId=other")),
95
+ ).toThrow("not allowed");
96
+ expect(() => decodeSearchRequestQueryV1(url("?q=gym&q=other"))).toThrow(
97
+ "repeated",
98
+ );
99
+ expect(() =>
100
+ decodeSearchRequestQueryV1(url("?q=gym&includeArchived=yes")),
101
+ ).toThrow("includeArchived is invalid");
102
+ expect(() =>
103
+ decodeSearchRequestQueryV1(url("?q=gym&kinds=secret")),
104
+ ).toThrow();
105
+ });
106
+ });
107
+
108
+ describe("the search route", () => {
109
+ test("declines a path it does not own and an unauthenticated request", async () => {
110
+ const route = createSearchBackendContribution(host());
111
+ expect(
112
+ await route.route(
113
+ new Request("https://frockbot.test/api/bots"),
114
+ new URL("https://frockbot.test/api/bots"),
115
+ CONTEXT,
116
+ ),
117
+ ).toBeUndefined();
118
+ expect(
119
+ await route.route(new Request(url("?q=gym")), url("?q=gym"), {
120
+ client: "browser",
121
+ }),
122
+ ).toBeUndefined();
123
+ });
124
+
125
+ test("answers a grouped page the client decoder accepts", async () => {
126
+ const route = createSearchBackendContribution(host());
127
+ const response = await route.route(
128
+ new Request(url("?q=gym")),
129
+ url("?q=gym"),
130
+ CONTEXT,
131
+ );
132
+ expect(response?.status).toBe(200);
133
+ const decoded = decodeClientSearchResultsV1(await response!.json());
134
+ expect(decoded.groups).toHaveLength(1);
135
+ expect(decoded.groups[0]).toMatchObject({
136
+ botId: "bot-a",
137
+ botName: "Site foreman",
138
+ archived: false,
139
+ });
140
+ expect(decoded.groups[0]!.hits[0]!.deepLink).toBe("/?bot=bot-a#turn-run-1");
141
+ });
142
+
143
+ test("marks a Bot archived from the live lifecycle directory", async () => {
144
+ const route = createSearchBackendContribution(
145
+ host({
146
+ listBotLifecycles: async () => ({
147
+ schemaVersion: 1,
148
+ lifecycles: [
149
+ {
150
+ schemaVersion: 1,
151
+ botId: "bot-a",
152
+ status: "archived",
153
+ revision: 1,
154
+ },
155
+ ],
156
+ }),
157
+ }),
158
+ );
159
+ const response = await route.route(
160
+ new Request(url("?q=gym&includeArchived=true")),
161
+ url("?q=gym&includeArchived=true"),
162
+ CONTEXT,
163
+ );
164
+ const decoded = decodeClientSearchResultsV1(await response!.json());
165
+ expect(decoded.groups[0]!.archived).toBe(true);
166
+ });
167
+
168
+ test("refuses an invalid query with a definitive 400", async () => {
169
+ const route = createSearchBackendContribution(host());
170
+ const response = await route.route(
171
+ new Request(url("?q=gym&nope=1")),
172
+ url("?q=gym&nope=1"),
173
+ CONTEXT,
174
+ );
175
+ expect(response?.status).toBe(400);
176
+ expect(await response!.json()).toMatchObject({
177
+ code: "invalid-request",
178
+ definitive: true,
179
+ });
180
+ });
181
+
182
+ test("rebuilds only on POST", async () => {
183
+ const route = createSearchBackendContribution(host());
184
+ const rebuildUrl = new URL("https://frockbot.test/api/search/rebuild");
185
+ expect(
186
+ (
187
+ await route.route(
188
+ new Request(rebuildUrl, { method: "GET" }),
189
+ rebuildUrl,
190
+ CONTEXT,
191
+ )
192
+ )?.status,
193
+ ).toBe(405);
194
+ const response = await route.route(
195
+ new Request(rebuildUrl, { method: "POST" }),
196
+ rebuildUrl,
197
+ CONTEXT,
198
+ );
199
+ expect(
200
+ decodeClientSearchRebuildReceiptV1(await response!.json()),
201
+ ).toMatchObject({ status: "rebuilt", indexedRows: 4 });
202
+ });
203
+
204
+ test("defaults to excluding tool rows and archived Bots", async () => {
205
+ const contribution = host();
206
+ const route = createSearchBackendContribution(contribution);
207
+ await route.route(new Request(url("?q=gym")), url("?q=gym"), CONTEXT);
208
+ expect(contribution.queries[0]).toEqual({ schemaVersion: 1, query: "gym" });
209
+ });
210
+ });
package/src/backend.ts ADDED
@@ -0,0 +1,216 @@
1
+ // The Search Package's gateway Contribution.
2
+ //
3
+ // It sits on the same host as Flock's `/api/bots`, for the same reason: the
4
+ // gateway is where an authenticated `userId` exists, and search is User-scoped
5
+ // by construction — there is no cross-User store to leak from, and the User
6
+ // Durable Object refuses any RPC naming a User it is not.
7
+ //
8
+ // The route owns no state. It decodes the query, asks the User Durable Object,
9
+ // joins the live Bot directory, and answers. Archived Bots are excluded unless
10
+ // asked for, and hidden Bots are returned labelled rather than dropped: the
11
+ // sidebar hides them, which is not the same as them not existing.
12
+ import type { Plugin } from "cordis";
13
+ import type {
14
+ BotIdentityDirectoryViewV1,
15
+ BotLifecycleDirectoryViewV1,
16
+ } from "@frockbot/plugin-flock/shared";
17
+ import { groupSearchHitsV1, type SearchBotDescriptorV1 } from "./groups.js";
18
+ import {
19
+ decodeSearchQueryV1,
20
+ SearchDecodeError,
21
+ SEARCH_MAX_CURSOR_LENGTH_V1,
22
+ SEARCH_MAX_QUERY_LENGTH_V1,
23
+ type ClientSearchRebuildReceiptV1,
24
+ type ClientSearchResultsV1,
25
+ type SearchIndexResultsV1,
26
+ type SearchQueryV1,
27
+ type SearchRowKindV1,
28
+ } from "./shared.js";
29
+
30
+ export interface SearchGatewayHost {
31
+ searchTranscripts(
32
+ userId: string,
33
+ query: SearchQueryV1,
34
+ ): Promise<SearchIndexResultsV1>;
35
+ rebuildSearchIndex(userId: string): Promise<ClientSearchRebuildReceiptV1>;
36
+ listBotIdentities(userId: string): Promise<BotIdentityDirectoryViewV1>;
37
+ listBotLifecycles(userId: string): Promise<BotLifecycleDirectoryViewV1>;
38
+ }
39
+
40
+ export interface SearchBackendRouteContribution {
41
+ packageId: string;
42
+ route(
43
+ request: Request,
44
+ url: URL,
45
+ context: { userId?: string; client: "browser" | "desktop" },
46
+ ): Promise<Response | undefined>;
47
+ }
48
+
49
+ const ALLOWED_PARAMS = new Set([
50
+ "q",
51
+ "before",
52
+ "kinds",
53
+ "botId",
54
+ "includeArchived",
55
+ ]);
56
+
57
+ /**
58
+ * The query string, decoded into the exact DTO.
59
+ *
60
+ * URL parameters are the loosest input the Package takes, so they are turned
61
+ * into the same `SearchQueryV1` every other caller uses and decoded by the
62
+ * same decoder. An unexpected parameter is a refusal rather than something
63
+ * quietly ignored, so a client that means something the route does not
64
+ * implement finds out.
65
+ */
66
+ export function decodeSearchRequestQueryV1(url: URL): SearchQueryV1 {
67
+ for (const key of url.searchParams.keys()) {
68
+ if (!ALLOWED_PARAMS.has(key)) {
69
+ throw new SearchDecodeError(`search query.${key} is not allowed`);
70
+ }
71
+ if (url.searchParams.getAll(key).length > 1) {
72
+ throw new SearchDecodeError(`search query.${key} is repeated`);
73
+ }
74
+ }
75
+ const query = url.searchParams.get("q") ?? "";
76
+ if (query.length > SEARCH_MAX_QUERY_LENGTH_V1) {
77
+ throw new SearchDecodeError("search query.q must be a bounded string");
78
+ }
79
+ const before = url.searchParams.get("before");
80
+ if (before !== null && before.length > SEARCH_MAX_CURSOR_LENGTH_V1) {
81
+ throw new SearchDecodeError("search query.before must be a bounded string");
82
+ }
83
+ const kinds = url.searchParams.get("kinds");
84
+ const includeArchived = url.searchParams.get("includeArchived");
85
+ if (
86
+ includeArchived !== null &&
87
+ !["true", "false"].includes(includeArchived)
88
+ ) {
89
+ throw new SearchDecodeError("search query.includeArchived is invalid");
90
+ }
91
+ const botId = url.searchParams.get("botId");
92
+ return decodeSearchQueryV1({
93
+ schemaVersion: 1,
94
+ query,
95
+ ...(before === null ? {} : { before }),
96
+ ...(kinds === null
97
+ ? {}
98
+ : {
99
+ kinds: kinds
100
+ .split(",")
101
+ .map((kind) => kind.trim())
102
+ .filter((kind) => kind.length > 0)
103
+ .filter(
104
+ (kind, index, all) => all.indexOf(kind) === index,
105
+ ) as SearchRowKindV1[],
106
+ }),
107
+ ...(botId === null ? {} : { botId }),
108
+ ...(includeArchived === null
109
+ ? {}
110
+ : { includeArchived: includeArchived === "true" }),
111
+ });
112
+ }
113
+
114
+ function errorResponse(error: unknown): Response {
115
+ if (
116
+ error instanceof SearchDecodeError ||
117
+ (typeof error === "object" &&
118
+ error !== null &&
119
+ "name" in error &&
120
+ error.name === "SearchDecodeError")
121
+ ) {
122
+ return Response.json(
123
+ {
124
+ error:
125
+ error instanceof Error ? error.message : "search request is invalid",
126
+ code: "invalid-request",
127
+ definitive: true,
128
+ },
129
+ { status: 400 },
130
+ );
131
+ }
132
+ return Response.json(
133
+ { error: error instanceof Error ? error.message : "search failed" },
134
+ { status: 500 },
135
+ );
136
+ }
137
+
138
+ /** The live directory a query is grouped and filtered against. */
139
+ async function readDirectory(
140
+ host: SearchGatewayHost,
141
+ userId: string,
142
+ ): Promise<SearchBotDescriptorV1[]> {
143
+ const [identities, lifecycles] = await Promise.all([
144
+ host.listBotIdentities(userId),
145
+ host.listBotLifecycles(userId),
146
+ ]);
147
+ const status = new Map(
148
+ lifecycles.lifecycles.map((lifecycle) => [
149
+ lifecycle.botId,
150
+ lifecycle.status,
151
+ ]),
152
+ );
153
+ return identities.identities.map((identity) => ({
154
+ botId: identity.botId,
155
+ name: identity.name,
156
+ archived: status.get(identity.botId) === "archived",
157
+ hidden: identity.hiddenFromSidebar,
158
+ }));
159
+ }
160
+
161
+ export function createSearchBackendContribution(
162
+ host: SearchGatewayHost,
163
+ ): SearchBackendRouteContribution {
164
+ return {
165
+ packageId: "search",
166
+ async route(request, url, context) {
167
+ if (!context.userId) return undefined;
168
+ const isSearch = url.pathname === "/api/search";
169
+ const isRebuild = url.pathname === "/api/search/rebuild";
170
+ if (!isSearch && !isRebuild) return undefined;
171
+ const userId = context.userId;
172
+ try {
173
+ if (isRebuild) {
174
+ if (request.method !== "POST") {
175
+ return Response.json(
176
+ { error: "method not allowed" },
177
+ { status: 405 },
178
+ );
179
+ }
180
+ return Response.json(await host.rebuildSearchIndex(userId));
181
+ }
182
+ if (request.method !== "GET") {
183
+ return Response.json(
184
+ { error: "method not allowed" },
185
+ { status: 405 },
186
+ );
187
+ }
188
+ const query = decodeSearchRequestQueryV1(url);
189
+ const [results, directory] = await Promise.all([
190
+ host.searchTranscripts(userId, query),
191
+ readDirectory(host, userId),
192
+ ]);
193
+ // Archiving is already applied inside the index, against this same
194
+ // live directory, so this route filters nothing: it only names the
195
+ // Bots the hits belong to. One filter, in the place that owns the
196
+ // rows, is the only way the two can never disagree.
197
+ const grouped: ClientSearchResultsV1 = groupSearchHitsV1(
198
+ results,
199
+ directory,
200
+ );
201
+ return Response.json(grouped);
202
+ } catch (error) {
203
+ return errorResponse(error);
204
+ }
205
+ },
206
+ };
207
+ }
208
+
209
+ export namespace createSearchBackendContribution {
210
+ export function plugin(
211
+ host: SearchGatewayHost,
212
+ lifecycle: { mount(value: SearchBackendRouteContribution): () => void },
213
+ ): Plugin {
214
+ return () => lifecycle.mount(createSearchBackendContribution(host));
215
+ }
216
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isSettledSearchRunV1,
4
+ searchRowsFromClientRunV1,
5
+ type SearchProjectableRunV1,
6
+ } from "./bot.ts";
7
+
8
+ function run(
9
+ overrides: Partial<SearchProjectableRunV1> = {},
10
+ ): SearchProjectableRunV1 {
11
+ return {
12
+ runId: "run-1",
13
+ admittedAt: "2026-08-31T00:00:00.000Z",
14
+ input: "How is the gym build going?",
15
+ status: "completed",
16
+ events: [],
17
+ responseText: "Framing is done.",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("the settled-run projection", () => {
23
+ test("projects the user input and the assistant answer", () => {
24
+ expect(searchRowsFromClientRunV1("bot-a", run())).toEqual([
25
+ {
26
+ botId: "bot-a",
27
+ runId: "run-1",
28
+ seq: 0,
29
+ kind: "user",
30
+ at: "2026-08-31T00:00:00.000Z",
31
+ body: "How is the gym build going?",
32
+ },
33
+ {
34
+ botId: "bot-a",
35
+ runId: "run-1",
36
+ seq: 1,
37
+ kind: "assistant",
38
+ at: "2026-08-31T00:00:00.000Z",
39
+ body: "Framing is done.",
40
+ },
41
+ ]);
42
+ });
43
+
44
+ test("projects a tool call with its result as one `tool` row", () => {
45
+ const rows = searchRowsFromClientRunV1(
46
+ "bot-a",
47
+ run({
48
+ events: [
49
+ { type: "tool/call", call: { id: "tool-1", name: "shell" } },
50
+ { type: "tool/result", callId: "tool-1", content: "ok" },
51
+ ],
52
+ }),
53
+ );
54
+ expect(rows.map((entry) => entry.kind)).toEqual([
55
+ "user",
56
+ "tool",
57
+ "assistant",
58
+ ]);
59
+ expect(rows[1]!.body).toBe("shell\nok");
60
+ });
61
+
62
+ test("projects nothing for a run that has not settled", () => {
63
+ expect(isSettledSearchRunV1({ status: "running" })).toBe(false);
64
+ expect(
65
+ searchRowsFromClientRunV1("bot-a", run({ status: "running" })),
66
+ ).toEqual([]);
67
+ });
68
+
69
+ test("a failed run still contributes its user input", () => {
70
+ const rows = searchRowsFromClientRunV1(
71
+ "bot-a",
72
+ run({
73
+ status: "failed",
74
+ responseText: undefined,
75
+ }),
76
+ );
77
+ expect(rows.map((entry) => entry.kind)).toEqual(["user"]);
78
+ });
79
+
80
+ test("is deterministic, so a rebuild reproduces the settlement-time rows", () => {
81
+ const settled = run({
82
+ events: [
83
+ { type: "tool/call", call: { id: "tool-1", name: "shell" } },
84
+ { type: "tool/result", callId: "tool-1", content: "ok" },
85
+ ],
86
+ });
87
+ expect(searchRowsFromClientRunV1("bot-a", settled)).toEqual(
88
+ searchRowsFromClientRunV1("bot-a", settled),
89
+ );
90
+ });
91
+
92
+ test("drops empty bodies without leaving a gap in `seq`", () => {
93
+ const rows = searchRowsFromClientRunV1(
94
+ "bot-a",
95
+ run({ responseText: " " }),
96
+ );
97
+ expect(rows.map((entry) => entry.seq)).toEqual([0]);
98
+ });
99
+
100
+ test("projects nothing for a run with no admission time to order it by", () => {
101
+ expect(
102
+ searchRowsFromClientRunV1("bot-a", run({ admittedAt: undefined })),
103
+ ).toEqual([]);
104
+ });
105
+ });