@frockbot/plugin-search 0.0.0 → 0.1.1

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/src/bot.ts ADDED
@@ -0,0 +1,125 @@
1
+ // The Bot half of the Search Package: the projection, and the sink it writes to.
2
+ //
3
+ // The kernel imports no Package, so nothing here is called from `kernel-do`.
4
+ // The Bot Durable Object projects a *settled* run — one that has already
5
+ // reached a durable terminal state — through the narrow `SearchSinkV1` binding
6
+ // its host constructs, exactly as the Memory Package reaches the User Durable
7
+ // Object through `MEMORY_PROJECTS` (`plugin-shell/src/backend-memory.ts`).
8
+ //
9
+ // Projection happens after settlement, never before it, so a failed index
10
+ // write loses nothing: the run is already durable in the Bot Durable Object,
11
+ // and `rebuildSearchIndex` reconstructs every row this call would have made.
12
+ import { boundSearchBodyV1, type SearchRowV1 } from "./shared.js";
13
+
14
+ /**
15
+ * The User-scoped index, as a Bot Durable Object calls it.
16
+ *
17
+ * `indexRows` is idempotent on `(botId, runId, seq)`, which is what lets the
18
+ * caller treat it as fire-and-forget: a retried Turn, a resumed Turn, and a
19
+ * rebuild all converge on the same rows.
20
+ */
21
+ export interface SearchSinkV1 {
22
+ indexRows(rows: readonly SearchRowV1[]): Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * The decoded run projection this Package reads.
27
+ *
28
+ * Structural on purpose: it is satisfied by both the wire `ClientRunV1` the
29
+ * Shell Package emits and the `ClientRun` its decoder returns, so the rows a
30
+ * Turn writes on settlement and the rows a rebuild reads back out of the run
31
+ * list come from one function rather than two that must agree.
32
+ */
33
+ export interface SearchProjectableRunV1 {
34
+ runId: string;
35
+ admittedAt?: string;
36
+ input: string;
37
+ status:
38
+ | "running"
39
+ | "completed"
40
+ | "failed"
41
+ | "cancelled"
42
+ | "reconciliation-required";
43
+ events: readonly {
44
+ type: string;
45
+ call?: { id: string; name: string };
46
+ callId?: string;
47
+ content?: string;
48
+ }[];
49
+ /**
50
+ * The settled assistant text. The wire DTO carries it as
51
+ * `outcome.text` and the decoded client value as `responseText`; both are
52
+ * read here so a Turn's settlement-time projection and a rebuild's cannot
53
+ * diverge on which shape they happened to be handed.
54
+ */
55
+ responseText?: string;
56
+ outcome?: { type: string; text?: string };
57
+ }
58
+
59
+ function assistantText(run: SearchProjectableRunV1): string | undefined {
60
+ if (run.responseText !== undefined) return run.responseText;
61
+ return run.outcome?.type === "completed" ? run.outcome.text : undefined;
62
+ }
63
+
64
+ /** A run is projected once it can no longer change. */
65
+ export function isSettledSearchRunV1(run: {
66
+ status: SearchProjectableRunV1["status"];
67
+ }): boolean {
68
+ return (
69
+ run.status === "completed" ||
70
+ run.status === "failed" ||
71
+ run.status === "cancelled"
72
+ );
73
+ }
74
+
75
+ /**
76
+ * The rows one settled run contributes, in a deterministic order.
77
+ *
78
+ * Determinism is the whole contract: `seq` is derived from the run's own
79
+ * projection and from nothing else, so the rows a Turn writes on settlement
80
+ * and the rows a rebuild writes months later are byte-for-byte identical, and
81
+ * re-projecting a run is a no-op rather than a duplicate.
82
+ *
83
+ * Body text only. Never a model request, never the Composition snapshot, never
84
+ * Memory — Memory has its own search, and a request is not conversation.
85
+ */
86
+ export function searchRowsFromClientRunV1(
87
+ botId: string,
88
+ run: SearchProjectableRunV1,
89
+ ): SearchRowV1[] {
90
+ // A run with no admission time cannot be ordered against the others, and the
91
+ // index orders by it; a row it cannot place is one it does not keep.
92
+ if (!isSettledSearchRunV1(run) || !run.admittedAt) return [];
93
+ const rows: SearchRowV1[] = [];
94
+ const at = run.admittedAt;
95
+ const push = (kind: SearchRowV1["kind"], body: string): void => {
96
+ const bounded = boundSearchBodyV1(body).trim();
97
+ if (bounded.length === 0) return;
98
+ rows.push({
99
+ botId,
100
+ runId: run.runId,
101
+ seq: rows.length,
102
+ kind,
103
+ at,
104
+ body: bounded,
105
+ });
106
+ };
107
+ push("user", run.input);
108
+ // Tool text is indexed but excluded from default results: a tool result can
109
+ // carry credentials-adjacent output, so reading it back is an explicit
110
+ // `kinds` opt-in rather than something a stray query surfaces.
111
+ const results = new Map<string, string>();
112
+ for (const event of run.events) {
113
+ if (event.type === "tool/result" && event.callId) {
114
+ results.set(event.callId, event.content ?? "");
115
+ }
116
+ }
117
+ for (const event of run.events) {
118
+ if (event.type !== "tool/call" || !event.call) continue;
119
+ const result = results.get(event.call.id);
120
+ push("tool", result ? `${event.call.name}\n${result}` : event.call.name);
121
+ }
122
+ const answer = run.status === "completed" ? assistantText(run) : undefined;
123
+ if (answer) push("assistant", answer);
124
+ return rows;
125
+ }
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import { UiIcon } from "@frockbot/client-ui";
3
+ import { inject } from "vue";
4
+ import { searchWebDataKey } from "./state.js";
5
+
6
+ const provided = inject(searchWebDataKey);
7
+ if (!provided) throw new Error("Search client data was not provided");
8
+ const search = provided;
9
+ </script>
10
+
11
+ <template>
12
+ <button
13
+ type="button"
14
+ class="search-box"
15
+ aria-label="Search every Bot's conversations"
16
+ @click="search.open()"
17
+ >
18
+ <UiIcon name="search" size="sm" />
19
+ <span class="search-box-label">Search</span>
20
+ </button>
21
+ </template>
@@ -0,0 +1,165 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Search across every Bot this User has.
4
+ *
5
+ * Every state is explicit and named: nothing typed yet, nothing found,
6
+ * rebuilding, and a truncated index. A blank panel that could mean any of the
7
+ * four is the one outcome this surface must never produce.
8
+ */
9
+ import { UiIcon, UiIconButton } from "@frockbot/client-ui";
10
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
11
+ import { inject } from "vue";
12
+ import { searchWebDataKey } from "./state.js";
13
+
14
+ const provided = inject(searchWebDataKey);
15
+ if (!provided) throw new Error("Search client data was not provided");
16
+ const search = provided;
17
+
18
+ const input = ref<HTMLInputElement>();
19
+ const DEBOUNCE_MS = 200;
20
+ let debounce: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ void nextTick(() => input.value?.focus());
23
+ watch(
24
+ () => search.value.query,
25
+ () => {
26
+ if (debounce) clearTimeout(debounce);
27
+ debounce = setTimeout(() => void search.value.run(), DEBOUNCE_MS);
28
+ },
29
+ );
30
+ onBeforeUnmount(() => {
31
+ if (debounce) clearTimeout(debounce);
32
+ });
33
+
34
+ const results = computed(() => search.value.results);
35
+ const hasQuery = computed(() => search.value.query.trim().length > 0);
36
+ const groups = computed(() => results.value?.groups ?? []);
37
+ const totalHits = computed(() =>
38
+ groups.value.reduce((sum, group) => sum + group.totalHits, 0),
39
+ );
40
+
41
+ function monogram(name: string): string {
42
+ return [...name.trim()][0]?.toUpperCase() ?? "?";
43
+ }
44
+
45
+ function kindLabel(kind: string): string {
46
+ if (kind === "user") return "You";
47
+ if (kind === "assistant") return "Reply";
48
+ if (kind === "tool") return "Tool";
49
+ return "Media";
50
+ }
51
+
52
+ function whenLabel(at: string): string {
53
+ const value = new Date(at);
54
+ return Number.isFinite(value.getTime()) ? value.toLocaleDateString() : "";
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <div class="search-surface">
60
+ <div class="search-input-row">
61
+ <UiIcon name="search" size="sm" />
62
+ <input
63
+ ref="input"
64
+ class="search-input"
65
+ type="search"
66
+ placeholder="Search every Bot's conversations"
67
+ aria-label="Search query"
68
+ :value="search.query"
69
+ @input="search.setQuery(($event.target as HTMLInputElement).value)"
70
+ />
71
+ </div>
72
+
73
+ <div class="search-filters">
74
+ <label class="search-filter">
75
+ <input
76
+ type="checkbox"
77
+ :checked="search.includeArchived"
78
+ @change="
79
+ search.setIncludeArchived(
80
+ ($event.target as HTMLInputElement).checked,
81
+ )
82
+ "
83
+ />
84
+ Archived Bots
85
+ </label>
86
+ <label class="search-filter">
87
+ <input
88
+ type="checkbox"
89
+ :checked="search.includeTools"
90
+ @change="
91
+ search.setIncludeTools(($event.target as HTMLInputElement).checked)
92
+ "
93
+ />
94
+ Tool output
95
+ </label>
96
+ <UiIconButton
97
+ class="search-rebuild"
98
+ icon="refresh"
99
+ label="Rebuild the search index"
100
+ size="sm"
101
+ :disabled="search.rebuilding"
102
+ @click="search.rebuild()"
103
+ />
104
+ </div>
105
+
106
+ <!--
107
+ The index is a projection, so a truncated or rebuilding one is reported
108
+ rather than quietly answering with less than it holds.
109
+ -->
110
+ <p v-if="search.indexState === 'rebuilding'" class="search-note">
111
+ Rebuilding the index from every Bot's stored turns. Results are incomplete
112
+ until it finishes.
113
+ </p>
114
+ <p v-else-if="search.indexState === 'truncated'" class="search-note">
115
+ This index reached its size limit, so the oldest turns were dropped.
116
+ Rebuilding will not bring them back.
117
+ </p>
118
+ <p v-if="search.error" class="search-error" role="alert">
119
+ {{ search.error }}
120
+ </p>
121
+
122
+ <p v-if="!hasQuery" class="search-empty">
123
+ Type to search every conversation this account has.
124
+ </p>
125
+ <p v-else-if="search.loading && !results" class="search-empty">
126
+ Searching…
127
+ </p>
128
+ <p v-else-if="results && totalHits === 0" class="search-empty">
129
+ No turns match “{{ results.query }}”.
130
+ </p>
131
+
132
+ <ol v-else-if="results" class="search-groups">
133
+ <li v-for="group in groups" :key="group.botId" class="search-group">
134
+ <div class="search-group-head">
135
+ <span class="search-group-avatar search-group-monogram">{{
136
+ monogram(group.botName)
137
+ }}</span>
138
+ <span class="search-group-name">{{ group.botName }}</span>
139
+ <span v-if="group.archived" class="search-tag">Archived</span>
140
+ <span v-else-if="group.hidden" class="search-tag">Hidden</span>
141
+ <span class="search-group-count">{{ group.totalHits }}</span>
142
+ </div>
143
+ <ol class="search-hits">
144
+ <li v-for="hit in group.hits" :key="`${hit.runId}-${hit.snippet}`">
145
+ <button
146
+ type="button"
147
+ class="search-hit"
148
+ @click="search.openHit(group.botId, hit.runId, hit.deepLink)"
149
+ >
150
+ <span class="search-hit-meta">
151
+ <span class="search-hit-kind">{{ kindLabel(hit.kind) }}</span>
152
+ <span class="search-hit-when">{{ whenLabel(hit.at) }}</span>
153
+ </span>
154
+ <span class="search-hit-snippet">{{ hit.snippet }}</span>
155
+ </button>
156
+ </li>
157
+ </ol>
158
+ </li>
159
+ </ol>
160
+
161
+ <p v-if="results?.page.truncated" class="search-note">
162
+ More matches than this page holds. Narrow the query to see them.
163
+ </p>
164
+ </div>
165
+ </template>
@@ -0,0 +1,192 @@
1
+ // The Search Package's hosted client Contribution.
2
+ //
3
+ // It registers one surface on the shell's surface registry, one header
4
+ // control, and one keyboard shortcut. Everything it renders comes from
5
+ // `GET /api/search`, decoded at the seam; the client holds no index and
6
+ // assembles no deep link of its own.
7
+ import {
8
+ clientSurfaceRegistryKey,
9
+ type ClientPlugin,
10
+ } from "@frockbot/client-core";
11
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
12
+ import { ref } from "vue";
13
+ import {
14
+ decodeClientSearchRebuildReceiptV1,
15
+ decodeClientSearchResultsV1,
16
+ searchTurnAnchorV1,
17
+ SEARCH_MAX_QUERY_LENGTH_V1,
18
+ } from "../shared.js";
19
+ import SearchBox from "./SearchBox.vue";
20
+ import SearchOverlay from "./SearchOverlay.vue";
21
+ import {
22
+ searchKindsV1,
23
+ searchWebDataKey,
24
+ type SearchWebData,
25
+ } from "./state.js";
26
+ import "./styles.css";
27
+
28
+ export const SEARCH_SURFACE_ID = "search";
29
+
30
+ /**
31
+ * How long the overlay waits for a deep-linked turn to render.
32
+ *
33
+ * A hit can name a Turn further back than the conversation's newest page, so
34
+ * the anchor may never appear. Waiting a bounded moment and then leaving the
35
+ * reader on the right Bot is the honest outcome; pretending to scroll is not.
36
+ */
37
+ const ANCHOR_TIMEOUT_MS = 4_000;
38
+ const ANCHOR_POLL_MS = 100;
39
+
40
+ async function scrollToTurn(runId: string): Promise<boolean> {
41
+ if (typeof document === "undefined") return false;
42
+ const anchor = searchTurnAnchorV1(runId);
43
+ const deadline = Date.now() + ANCHOR_TIMEOUT_MS;
44
+ for (;;) {
45
+ const element = document.getElementById(anchor);
46
+ if (element) {
47
+ element.scrollIntoView({ block: "center", behavior: "smooth" });
48
+ return true;
49
+ }
50
+ if (Date.now() >= deadline) return false;
51
+ await new Promise((resolve) => setTimeout(resolve, ANCHOR_POLL_MS));
52
+ }
53
+ }
54
+
55
+ export const searchClientPlugin: ClientPlugin = (ctx) => {
56
+ if (!ctx.transport.hostedRequest) {
57
+ throw new Error("Search hosted transport is unavailable");
58
+ }
59
+ const request = ctx.transport.hostedRequest.bind(ctx.transport);
60
+ const surfaces = ctx.inject(clientSurfaceRegistryKey);
61
+ const shell = ctx.inject(frockBotWebDataKey);
62
+ let queryGeneration = 0;
63
+
64
+ const state = ref<SearchWebData>({
65
+ query: "",
66
+ loading: false,
67
+ rebuilding: false,
68
+ includeArchived: false,
69
+ includeTools: false,
70
+ indexState: "ready",
71
+ setQuery(value) {
72
+ state.value.query = value.slice(0, SEARCH_MAX_QUERY_LENGTH_V1);
73
+ },
74
+ setIncludeArchived(value) {
75
+ state.value.includeArchived = value;
76
+ void state.value.run();
77
+ },
78
+ setIncludeTools(value) {
79
+ state.value.includeTools = value;
80
+ void state.value.run();
81
+ },
82
+ async run() {
83
+ const generation = ++queryGeneration;
84
+ const query = state.value.query.trim();
85
+ if (!query) {
86
+ state.value.results = undefined;
87
+ state.value.loading = false;
88
+ state.value.error = undefined;
89
+ return;
90
+ }
91
+ state.value.loading = true;
92
+ state.value.error = undefined;
93
+ try {
94
+ const params = new URLSearchParams({ q: query });
95
+ params.set("kinds", searchKindsV1(state.value.includeTools).join(","));
96
+ if (state.value.includeArchived) params.set("includeArchived", "true");
97
+ const results = decodeClientSearchResultsV1(
98
+ await request(`/api/search?${params.toString()}`),
99
+ );
100
+ // A slower earlier query must never overwrite a newer answer.
101
+ if (generation !== queryGeneration) return;
102
+ state.value.results = results;
103
+ state.value.indexState = results.indexState;
104
+ } catch (error) {
105
+ if (generation !== queryGeneration) return;
106
+ state.value.error =
107
+ error instanceof Error ? error.message : "Search failed";
108
+ } finally {
109
+ if (generation === queryGeneration) state.value.loading = false;
110
+ }
111
+ },
112
+ async rebuild() {
113
+ state.value.rebuilding = true;
114
+ state.value.indexState = "rebuilding";
115
+ state.value.error = undefined;
116
+ try {
117
+ const receipt = decodeClientSearchRebuildReceiptV1(
118
+ await request("/api/search/rebuild", "POST", "{}"),
119
+ );
120
+ state.value.indexState = receipt.indexState;
121
+ await state.value.run();
122
+ } catch (error) {
123
+ state.value.error =
124
+ error instanceof Error ? error.message : "Rebuild failed";
125
+ } finally {
126
+ state.value.rebuilding = false;
127
+ }
128
+ },
129
+ async openHit(botId, runId, deepLink) {
130
+ surfaces.close();
131
+ try {
132
+ if (shell.value.activeBotId !== botId) {
133
+ await shell.value.selectBot(botId);
134
+ }
135
+ // The link is the real URL the route handed back, so a reader can copy
136
+ // it, and a reload lands on the same Bot and the same anchor.
137
+ if (typeof window !== "undefined") {
138
+ window.history.replaceState(
139
+ window.history.state,
140
+ "",
141
+ new URL(deepLink, window.location.href),
142
+ );
143
+ }
144
+ if (!(await scrollToTurn(runId))) {
145
+ state.value.error =
146
+ "That turn is further back than the loaded conversation; scroll up to reach it.";
147
+ }
148
+ } catch (error) {
149
+ state.value.error =
150
+ error instanceof Error ? error.message : "Could not open that turn";
151
+ }
152
+ },
153
+ open() {
154
+ surfaces.open(SEARCH_SURFACE_ID);
155
+ },
156
+ close() {
157
+ surfaces.close();
158
+ },
159
+ });
160
+
161
+ const onKeydown = (event: KeyboardEvent): void => {
162
+ if (event.key !== "k" && event.key !== "K") return;
163
+ if (!event.metaKey && !event.ctrlKey) return;
164
+ if (event.altKey) return;
165
+ event.preventDefault();
166
+ state.value.open();
167
+ };
168
+ if (typeof window !== "undefined") {
169
+ window.addEventListener("keydown", onKeydown);
170
+ }
171
+
172
+ return [
173
+ ctx.provide(searchWebDataKey, state),
174
+ surfaces.register({
175
+ id: SEARCH_SURFACE_ID,
176
+ title: "Search",
177
+ component: SearchOverlay,
178
+ }),
179
+ ctx.slot({
180
+ slot: "frockbot.sidebar-top",
181
+ order: 10,
182
+ component: SearchBox,
183
+ }),
184
+ () => {
185
+ if (typeof window !== "undefined") {
186
+ window.removeEventListener("keydown", onKeydown);
187
+ }
188
+ },
189
+ ];
190
+ };
191
+
192
+ export default searchClientPlugin;
@@ -0,0 +1,44 @@
1
+ import type { InjectionKey, Ref } from "vue";
2
+ import type {
3
+ ClientSearchResultsV1,
4
+ SearchIndexStateV1,
5
+ SearchRowKindV1,
6
+ } from "../shared.js";
7
+
8
+ /**
9
+ * The Search surface's client state.
10
+ *
11
+ * It renders backend state and submits commands; it is not a second authority.
12
+ * The index state, the grouping, and every deep link come from the route, so
13
+ * the overlay never assembles one out of parts it might get wrong.
14
+ */
15
+ export interface SearchWebData {
16
+ query: string;
17
+ /** Undefined until a query has been run at all: "empty" and "no results" differ. */
18
+ results?: ClientSearchResultsV1;
19
+ loading: boolean;
20
+ rebuilding: boolean;
21
+ error?: string;
22
+ includeArchived: boolean;
23
+ includeTools: boolean;
24
+ indexState: SearchIndexStateV1;
25
+ /** Runs the current query, debounced by the caller. */
26
+ run(): Promise<void>;
27
+ setQuery(value: string): void;
28
+ setIncludeArchived(value: boolean): void;
29
+ setIncludeTools(value: boolean): void;
30
+ /** Rebuilds the whole index from the Bots' own stored runs. */
31
+ rebuild(): Promise<void>;
32
+ /** Switches to the Bot and scrolls its conversation to the turn. */
33
+ openHit(botId: string, runId: string, deepLink: string): Promise<void>;
34
+ open(): void;
35
+ close(): void;
36
+ }
37
+
38
+ export function searchKindsV1(includeTools: boolean): SearchRowKindV1[] {
39
+ return includeTools ? ["user", "assistant", "tool"] : ["user", "assistant"];
40
+ }
41
+
42
+ export const searchWebDataKey: InjectionKey<Ref<SearchWebData>> = Symbol(
43
+ "frockbot-search-web-data",
44
+ );