diff --git a/packages/web/src/api/compareQueries.ts b/packages/web/src/api/compareQueries.ts index ed7ad99..f668e50 100644 --- a/packages/web/src/api/compareQueries.ts +++ b/packages/web/src/api/compareQueries.ts @@ -27,7 +27,7 @@ export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) queryFn: async () => { const client = createScopedClient(instance); const { data, error } = await client.POST("/v3/workspaces/list", { - params: { query: { page, page_size: pageSize } }, + params: { query: { page, size: pageSize } }, body: {}, }); return data ?? err(error); @@ -41,7 +41,7 @@ export function useScopedPeers(instance: Instance, workspaceId: string, page = 1 queryFn: async () => { const client = createScopedClient(instance); const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/peers/list", { - params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } }, + params: { path: { workspace_id: workspaceId }, query: { page, size: pageSize } }, body: {}, }); return data ?? err(error); diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index b4dd926..4572c6f 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -15,7 +15,7 @@ export function useWorkspaces(page = 1, pageSize = 20) { queryKey: QK.workspaces(page, pageSize), queryFn: async () => { const { data, error } = await client.current.POST("/v3/workspaces/list", { - params: { query: { page, page_size: pageSize } }, + params: { query: { page, size: pageSize } }, body: {}, }); return data ?? err(error); @@ -139,7 +139,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) { const { data, error } = await client.current.POST( "/v3/workspaces/{workspace_id}/peers/list", { - params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } }, + params: { path: { workspace_id: workspaceId }, query: { page, size: pageSize } }, body: {}, }, ); @@ -253,7 +253,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p { params: { path: { workspace_id: workspaceId, peer_id: peerId }, - query: { page, page_size: pageSize }, + query: { page, size: pageSize }, }, body: {}, }, @@ -323,7 +323,7 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) { { params: { path: { workspace_id: workspaceId }, - query: { page, page_size: pageSize }, + query: { page, size: pageSize }, }, body: {}, }, @@ -412,7 +412,7 @@ export function useSessionMessages( { params: { path: { workspace_id: workspaceId, session_id: sessionId }, - query: { page, page_size: pageSize }, + query: { page, size: pageSize }, }, body: {}, }, @@ -627,6 +627,11 @@ export function useSessionContext(workspaceId: string, sessionId: string) { // ─── Conclusions ────────────────────────────────────────────────────────────── +// Target-discovery walk bounds (see useConclusionTargetPeers): enough for +// realistically sized per-observer stores; discovery is a read-only listing. +const CONCLUSION_TARGET_PAGE_SIZE = 100; +const CONCLUSION_TARGET_MAX_PAGES = 40; + export function useConclusions( workspaceId: string, filters: Record = {}, @@ -642,7 +647,7 @@ export function useConclusions( { params: { path: { workspace_id: workspaceId }, - query: { page, page_size: pageSize, reverse }, + query: { page, size: pageSize, reverse }, }, body: filters, }, @@ -675,6 +680,109 @@ export function useQueryConclusions( }); } +/** + * Discover the actual conclusion target peers available for one observer peer. + * + * Walks `conclusions/list` (paginated, `page`/`size`) with `filters.observer_id` + * and collects the distinct `observed_id` values from real stored conclusions. + * This is what powers the target picker: the user picks among targets that + * actually exist instead of guessing peer ids into an empty self-scope. + * + * Returns `{ targets, complete }`: `targets` is a sorted list of `{ id, count }` + * (count = number of stored rows for that target, i.e. provenance strength), + * `complete` is false when the walk hit the page cap before listing every page + * (targets may then be incomplete). Empty list = this observer holds no + * conclusions at all. + * + * The knowledge panel previously self-scoped `observed` to the peer, which is + * always empty for knowledge-holder peers (a peer that holds knowledge *about* + * other peers holds nothing about itself). + */ +export function useConclusionTargetPeers(workspaceId: string, observerPeerId: string) { + return useQuery({ + queryKey: ["conclusions-target-peers", workspaceId, observerPeerId] as const, + queryFn: async () => { + const counters = new Map(); + let page = 1; + let pages = 1; + let completed = false; + while (page <= CONCLUSION_TARGET_MAX_PAGES) { + const { data, error } = await client.current.POST( + "/v3/workspaces/{workspace_id}/conclusions/list", + { + params: { + path: { workspace_id: workspaceId }, + query: { page, size: CONCLUSION_TARGET_PAGE_SIZE, reverse: false }, + }, + body: { filters: { observer_id: observerPeerId } }, + }, + ); + if (error) err(error); + const items = data.items ?? []; + for (const item of items) { + if (item.observed_id) { + counters.set(item.observed_id, (counters.get(item.observed_id) ?? 0) + 1); + } + } + pages = data.pages ?? 1; + if (page >= pages || items.length === 0) { + completed = true; + break; + } + page += 1; + } + return { + targets: Array.from(counters.entries()) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count || a.id.localeCompare(b.id)), + complete: completed, + }; + }, + enabled: Boolean(workspaceId) && Boolean(observerPeerId), + }); +} + +/** + * Semantic search over conclusions in the observer/observed scope of one peer. + * + * Unlike `useSearchPeer` (message search on the peer itself), this queries the + * dedicated conclusions store with the peer as the *observer*, so it also finds + * knowledge the peer holds *about other peers*. + * The server requires both an observer and an observed peer; the observed target + * MUST be resolved by the caller (target-picker / auto-selection in PeerDetail). + * No implicit self-scope: self-scoping produced guaranteed-zero results. + */ +export function useQueryPeerConclusions( + workspaceId: string, + peerId: string, + query: string, + observedPeerId: string | null, + enabled = false, +) { + const filters: Record = { observer: peerId, observed: observedPeerId ?? "" }; + return useQuery({ + queryKey: QK.conclusionsQuery(workspaceId, query, { ...filters, scope: "peer" }), + queryFn: async () => { + const { data, error } = await client.current.POST( + "/v3/workspaces/{workspace_id}/conclusions/query", + { + params: { path: { workspace_id: workspaceId } }, + body: { query, top_k: 25, filters }, + }, + ); + return data ?? err(error); + }, + // Never fire with an empty target: the server requires both filters, and + // an observed="" request is a guaranteed zero (or worse, an error). + enabled: + enabled && + Boolean(workspaceId) && + Boolean(peerId) && + Boolean(query) && + Boolean(observedPeerId), + }); +} + export function useCreateConclusion(workspaceId: string) { const qc = useQueryClient(); return useMutation({ @@ -746,7 +854,7 @@ export function useDreams( { params: { path: { workspace_id: workspaceId }, - query: { page, page_size: pageSize, reverse: false }, + query: { page, size: pageSize, reverse: false }, }, body: filters, }, diff --git a/packages/web/src/components/peers/PeerDetail.tsx b/packages/web/src/components/peers/PeerDetail.tsx index 99e75f9..3d89fcb 100644 --- a/packages/web/src/components/peers/PeerDetail.tsx +++ b/packages/web/src/components/peers/PeerDetail.tsx @@ -1,6 +1,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { + Brain, Check, Eye, EyeOff, @@ -13,16 +14,19 @@ import { Users, X, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { + useConclusionTargetPeers, usePeer, usePeerCard, usePeerContext, usePeerRepresentation, + useQueryPeerConclusions, useSearchPeer, useSetPeerCard, useUpdatePeer, } from "@/api/queries"; +import type { components } from "@/api/schema.d.ts"; import { Breadcrumb } from "@/components/layout/Breadcrumb"; import { Badge } from "@/components/shared/Badge"; import { ErrorAlert } from "@/components/shared/ErrorAlert"; @@ -73,6 +77,61 @@ export function PeerDetail() { const [cardDraft, setCardDraft] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + // Knowledge/conclusion search: scoped to this peer as observer. The observed + // target is DISCOVERED from the conclusions this peer actually holds (no + // hardcoded peer names, no unrestricted fallback): when exactly one target + // exists it is auto-selected; otherwise the user picks it explicitly from + // the picker. Typing a free-form name is no longer the only path. + const [knowledgeQuery, setKnowledgeQuery] = useState(""); + const [activeKnowledgeQuery, setActiveKnowledgeQuery] = useState(""); + const [knowledgeTarget, setKnowledgeTarget] = useState(null); + const { + data: discovery, + isLoading: discoveryLoading, + error: discoveryError, + } = useConclusionTargetPeers(workspaceId, peerId); + const targetPeers = discovery?.targets ?? []; + const discoveryIncomplete = discovery !== undefined && !discovery.complete; + + // Reset per-peer target state when the viewed workspace or peer changes: a + // stale target from the previous context would silently scope the knowledge + // search wrong and a burnt auto-select flag would disable auto-picking for + // the new context. + const prevScopeRef = useRef(`${workspaceId}\u0000${peerId}`); + useEffect(() => { + const scope = `${workspaceId}\u0000${peerId}`; + if (prevScopeRef.current !== scope) { + prevScopeRef.current = scope; + setKnowledgeTarget(null); + setTargetAutoSelected(false); + } + }, [workspaceId, peerId]); + + // Auto-select when exactly one real target exists. Only when nothing is + // picked yet, so an explicit user choice wins, and only when discovery is + // COMPLETE — a single target found by a cap-truncated walk is not proven to + // be the only one. + const [targetAutoSelected, setTargetAutoSelected] = useState(false); + useEffect(() => { + if (targetAutoSelected || knowledgeTarget !== null || !discovery) return; + if (discovery.complete && targetPeers.length === 1) { + setKnowledgeTarget(targetPeers[0].id); + setTargetAutoSelected(true); + } + }, [targetAutoSelected, knowledgeTarget, discovery, targetPeers]); + + const { + data: knowledgeResults, + isLoading: knowledgeLoading, + error: knowledgeError, + } = useQueryPeerConclusions( + workspaceId, + peerId, + activeKnowledgeQuery, + knowledgeTarget, + Boolean(activeKnowledgeQuery), + ); + const peerMeta = (peer as { metadata?: Record } | undefined)?.metadata; const displayName = peerDisplayName(peerMeta, peerId); const showsDisplayName = hasDisplayName(peerMeta, peerId); @@ -285,6 +344,166 @@ export function PeerDetail() { + {/* Knowledge search — conclusions this peer holds, scoped observer → observed */} + + + + Search peer knowledge (conclusions) + + + Semantic search over distilled conclusions this peer holds — not raw messages. + + {/* Selected scope, shown up front: observer → discovered target. The + target comes from a picker over REAL stored targets, never a guess. */} +
+ + scope: {mask(peerId)} → {mask(knowledgeTarget ?? "(select observed peer)")} + + {discoveryIncomplete && ( + + Target list may be incomplete (store exceeds the discovery page cap). + + )} + {discoveryError && ( + Target discovery failed — knowledge search unavailable. + )} + {!discoveryLoading && !discoveryError && targetPeers.length === 0 && ( + This peer holds no conclusions yet. + )} +
+
{ + e.preventDefault(); + setActiveKnowledgeQuery(knowledgeQuery.trim()); + }} + className="flex gap-2 mb-4 flex-wrap" + > + setKnowledgeQuery(e.target.value)} + placeholder="Search this peer's conclusions…" + className="flex-1 text-sm min-w-48" + /> + + + {activeKnowledgeQuery && ( + + )} +
+ + + {activeKnowledgeQuery && ( + + {knowledgeLoading ? ( + + ) : !knowledgeTarget ? ( + + Pick an observed peer above — targets are listed from the conclusions this + peer actually holds. + + ) : !Array.isArray(knowledgeResults) || + (knowledgeResults as components["schemas"]["Conclusion"][]).length === 0 ? ( + No conclusions found for this scope. + ) : ( + /* Display-only dedup: identical contents repeat heavily in the + store (bulk import). Collapse them here — the database is NOT + touched (no data lane in this repair). First occurrence wins; + provenance (observer→observed, session, timestamp) is kept + from that first row. */ + (() => { + const all = knowledgeResults as components["schemas"]["Conclusion"][]; + const seenContent = new Set(); + const deduped = all.filter((c) => { + if (seenContent.has(c.content)) return false; + seenContent.add(c.content); + return true; + }); + const hidden = all.length - deduped.length; + return ( + <> + {deduped.map((c) => ( +
+
+ + {mask(c.observer_id)} → {mask(c.observed_id)} + + {c.session_id && session: {mask(c.session_id)}} + {c.created_at && ( + {new Date(c.created_at).toLocaleString()} + )} +
+ {mask(c.content)} +
+ ))} + {hidden > 0 && ( + + {deduped.length} unique result{deduped.length === 1 ? "" : "s"} ( + {hidden} identical duplicate{hidden === 1 ? "" : "s"} hidden — + display only, no data changed) + + )} + + ); + })() + )} +
+ )} +
+
+ {/* Card + Representation — side by side */}
{/* Peer Card */} diff --git a/packages/web/src/components/seed-kits/ApplyKitDialog.tsx b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx index 245b488..04e7d93 100644 --- a/packages/web/src/components/seed-kits/ApplyKitDialog.tsx +++ b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx @@ -33,7 +33,7 @@ function useScopedWorkspacesAll(instance: Instance | null) { if (!instance) return [] as Array<{ id: string }>; const client = createScopedClient(instance); const { data, error } = await client.POST("/v3/workspaces/list", { - params: { query: { page: 1, page_size: 100 } }, + params: { query: { page: 1, size: 100 } }, body: {}, }); const payload = data ?? err(error); diff --git a/packages/web/src/lib/discovery.ts b/packages/web/src/lib/discovery.ts index 6784f80..c5b4935 100644 --- a/packages/web/src/lib/discovery.ts +++ b/packages/web/src/lib/discovery.ts @@ -38,7 +38,7 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string { export async function suggestNameForInstance(baseUrl: string): Promise { try { const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl }); - const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, { + const res = await fetch(`${base}/v3/workspaces/list?page=1&size=1`, { method: "POST", headers, body: JSON.stringify({}), diff --git a/packages/web/src/test/conclusion-targets.test.tsx b/packages/web/src/test/conclusion-targets.test.tsx new file mode 100644 index 0000000..bae19f7 --- /dev/null +++ b/packages/web/src/test/conclusion-targets.test.tsx @@ -0,0 +1,197 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useConclusionTargetPeers } from "@/api/queries"; + +const post = vi.hoisted(() => vi.fn()); +vi.mock("@/api/client", () => ({ + client: { current: { POST: post } }, +})); +vi.mock("@/api/scopedClient", () => ({ createScopedClient: vi.fn() })); + +function wrap(qc: QueryClient) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +type ListResponse = { + items: Array<{ observer_id: string | null; observed_id: string | null }>; + pages: number; +}; + +function listPage(items: ListResponse["items"], pages: number): ListResponse { + return { items, pages }; +} + +function conclusion(observer: string | null, observed: string | null) { + return { observer_id: observer, observed_id: observed }; +} + +beforeEach(() => { + post.mockReset(); + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe("useConclusionTargetPeers", () => { + it("collects distinct observed targets with row counts from a complete walk", async () => { + post + .mockResolvedValueOnce({ + data: listPage( + [ + conclusion("observer-a", "target-1"), + conclusion("observer-a", "target-1"), + conclusion("observer-a", "target-2"), + ], + 1, + ), + }) + .mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data).toEqual({ + targets: [ + { id: "target-1", count: 2 }, + { id: "target-2", count: 1 }, + ], + complete: true, + }); + }); + + it("walks multiple pages until the last page and reports complete", async () => { + post + .mockResolvedValueOnce({ + data: listPage([conclusion("observer-a", "target-1")], 2), + }) + .mockResolvedValueOnce({ + data: listPage([conclusion("observer-a", "target-2")], 2), + }) + .mockResolvedValue({ data: listPage([], 2) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(true); + expect(result.current.data?.targets).toHaveLength(2); + }); + + it("reports complete=false when the store exceeds the page cap", async () => { + // Every page reports more pages ahead; the walk must stop at the cap + // and must NOT report the truncated result as complete. + post.mockImplementation(async (_url: string, opts: { params: { query: { page: number } } }) => { + const page = opts.params.query.page; + if (page <= 40) { + return { data: listPage([conclusion("observer-a", "target-1")], page + 1) }; + } + throw new Error("walk should have stopped at the page cap"); + }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(false); + expect(result.current.data?.targets).toHaveLength(1); + expect(post).toHaveBeenCalledTimes(40); + }); + + it("reports complete=false at the page-41 boundary (pages == cap + 1)", async () => { + // Regression: with the server reporting exactly MAX_PAGES + 1 pages, the + // walk exits the loop with page === MAX_PAGES + 1. Recomputing + // completeness as `page >= pages` after the loop compared 41 >= 41 and + // wrongly reported a cap-truncated walk as complete. Completeness must + // come from the break reason (normal page exhaustion) instead. + post.mockImplementation(async () => ({ + data: listPage([conclusion("observer-a", "target-1")], 41), + })); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(false); + expect(post).toHaveBeenCalledTimes(40); + }); + + it("reports complete=true when the last walked page is exactly the reported page count", async () => { + // pages == cap: the walk finishes on the final allowed page and the + // result is genuinely complete (regression for the off-by-one where a + // cap-truncated walk compared page > pages after the loop). + let page = 0; + post.mockImplementation(async () => { + page += 1; + return { data: listPage([conclusion("observer-a", "target-1")], 40) }; + }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(page).toBe(40); + expect(result.current.data?.complete).toBe(true); + }); + + it("surfaces errors instead of swallowing them", async () => { + post.mockResolvedValue({ error: { message: "boom" } }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + }); + + it("returns an empty complete target list for an observer with no conclusions", async () => { + post.mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data).toEqual({ targets: [], complete: true }); + }); + + it("sorts targets by descending count, then id", async () => { + post + .mockResolvedValueOnce({ + data: listPage( + [ + conclusion("observer-a", "b-target"), + conclusion("observer-a", "a-target"), + conclusion("observer-a", "a-target"), + ], + 1, + ), + }) + .mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.targets.map((t) => t.id)).toEqual(["a-target", "b-target"]); + }); +});