From 0d55e0ca742a6ca02b2401846a9c059774178c50 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 15:26:02 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(v3.9):=20close=20user-facing=20tails?= =?UTF-8?q?=20=E2=80=94=20Wiki=20surface,=20Goal-start=20button=E2=80=A6?= =?UTF-8?q?=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …, prod-wiring Close the three remaining V3.9 user-reachability tails surfaced by the commit-vs-design audit. Capability bodies were all implemented+tested; these wire them to users. Wiki (§B): - 4 HTTP routes (/deepagent/wiki/{pages,page,search,edit}), all fail-closed on experimentalWiki; capabilities.features += wiki for independent UI gating - openWikiSearchIndex factory (FTS5, runtime-split sqlite, rebuildable projection) - buildWikiEditGate: editKnowledge now goes through the REAL promotion.validate evidence-gate (dedupe + evidence check), not the trivial default floor - DialogWiki + wiki.api.ts: sidebar entry → dialog with grouped list, full-text search, Markdown detail, docs↔code cross-links, in-place Knowledge/Memory edit (dialog over route: the only wired full-view mechanism in the app today) Goal (§D): - GoalStartButton: "run plan as goal" trigger next to GoalStatusBar; startGoal with no objective makes the server adopt the existing session plan (no backend change). Gated on capabilities.goalLoop + presence of a plan + no active goal. Runtime-portable sqlite (the crash fix): - WikiSearchIndex previously hardcoded `bun:sqlite`. Statically importing it pulls a `bun:` builtin into the Node/Electron server module graph (prompt.ts → session-archive → search-index), crashing the desktop app at module-eval. Split into #wiki-fts-db (bun:sqlite / node:sqlite variants) via package.json imports conditions — the same convention as core #sqlite/#db. + a regression guard test. Cosmetic: - agent-executor.ts JSDoc: no longer claims it avoids UnifiedContextGraph - arbiter.ts: critical-dissent comment "Rule 3" → "Rule 5" (matches code order) Tests: +8 (edit-gate, search-index construction, runtime-portability guard). All 3 packages typecheck exit0; wiki/panel/goal/agent suites + i18n parity green. ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ Co-authored-by: Claude Opus 4.8 --- .../deepagent/goal-start-button.tsx | 82 ++++ .../deepagent/panel-goal-contract.test.ts | 7 +- .../components/deepagent/panel-goal.api.ts | 2 + .../app/src/components/wiki/dialog-wiki.tsx | 350 ++++++++++++++++++ packages/app/src/components/wiki/wiki.api.ts | 107 ++++++ packages/app/src/i18n/en.ts | 23 ++ packages/app/src/i18n/zh.ts | 23 ++ packages/app/src/i18n/zht.ts | 23 ++ packages/app/src/pages/layout.tsx | 26 ++ .../app/src/pages/layout/sidebar-shell.tsx | 14 + .../composer/session-composer-region.tsx | 2 + packages/core/src/im/agent-executor.ts | 4 +- packages/deepagent-code/package.json | 5 + packages/deepagent-code/src/panel/arbiter.ts | 4 +- .../instance/httpapi/groups/deepagent.ts | 109 ++++++ .../routes/instance/httpapi/groups/global.ts | 1 + .../instance/httpapi/handlers/deepagent.ts | 102 +++++ .../instance/httpapi/handlers/global.ts | 1 + .../deepagent-code/src/wiki/search-index.ts | 51 ++- .../src/wiki/session-archive.ts | 88 ++++- .../src/wiki/wiki-fts-db.bun.ts | 33 ++ .../src/wiki/wiki-fts-db.node.ts | 35 ++ .../deepagent-code/src/wiki/wiki-fts-db.ts | 41 ++ .../test/wiki/search-index.test.ts | 15 + .../test/wiki/session-archive-wiring.test.ts | 109 ++++++ 25 files changed, 1227 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/components/deepagent/goal-start-button.tsx create mode 100644 packages/app/src/components/wiki/dialog-wiki.tsx create mode 100644 packages/app/src/components/wiki/wiki.api.ts create mode 100644 packages/deepagent-code/src/wiki/wiki-fts-db.bun.ts create mode 100644 packages/deepagent-code/src/wiki/wiki-fts-db.node.ts create mode 100644 packages/deepagent-code/src/wiki/wiki-fts-db.ts create mode 100644 packages/deepagent-code/test/wiki/session-archive-wiring.test.ts diff --git a/packages/app/src/components/deepagent/goal-start-button.tsx b/packages/app/src/components/deepagent/goal-start-button.tsx new file mode 100644 index 00000000..3a89ee63 --- /dev/null +++ b/packages/app/src/components/deepagent/goal-start-button.tsx @@ -0,0 +1,82 @@ +import { Show, createMemo, createResource, createSignal } from "solid-js" +import { Button } from "@deepagent-code/ui/button" +import { Icon } from "@deepagent-code/ui/icon" +import { useServerSync } from "@/context/server-sync" +import { useSDK } from "@/context/sdk" +import { useLanguage } from "@/context/language" +import { showToast } from "@/utils/toast" +import { fetchCapabilities, startGoal, type PanelGoalClient } from "./panel-goal.api" + +/** + * V3.9 §D — "convert plan → goal" starter. + * + * The product path (per the goal-loop design): the user first produces a plan in plan mode, then + * converts it into a supervised long-running goal. `startGoal({ sessionID })` with NO objective makes + * the server read the session's existing plan (GoalManager.start → getPlan) and materialize it as the + * goal carrier — so this button is a pure trigger with zero backend change. + * + * Visibility: only when goalLoop is enabled (capability), a plan exists for this session, and no goal + * is already active (once a goal starts, GoalStatusBar takes over via the goal.updated event). + */ +export function GoalStartButton(props: { sessionID: string }) { + const sdk = useSDK() + const serverSync = useServerSync() + const language = useLanguage() + const [busy, setBusy] = createSignal(false) + + const client = () => sdk.client as unknown as PanelGoalClient + + const [capabilities] = createResource( + () => (props.sessionID ? "capabilities" : undefined), + () => fetchCapabilities(client()), + ) + const goalAvailable = createMemo(() => capabilities()?.goalLoop === true) + + const plan = createMemo(() => (props.sessionID ? serverSync.data.session_plan[props.sessionID] : undefined)) + const hasPlan = createMemo(() => (plan()?.steps.length ?? 0) > 0) + + // A goal is "live" for this session iff the persistent session_goal pointer exists and is not in a + // terminal phase the user has dismissed — while present, GoalStatusBar owns the surface. + const activeGoal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined)) + + const show = createMemo(() => goalAvailable() && hasPlan() && !activeGoal()) + + const onStart = async () => { + if (busy() || !props.sessionID) return + setBusy(true) + try { + const snapshot = await startGoal(client(), { sessionID: props.sessionID }) + if (!snapshot) { + showToast({ title: language.t("goal.start.failed") }) + } + // On success the goal.updated event drives GoalStatusBar to appear; nothing to do here. + } catch (err) { + const description = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("goal.start.failed"), description }) + } finally { + setBusy(false) + } + } + + return ( + +
+ + {language.t("goal.start.hint")} + +
+
+ ) +} diff --git a/packages/app/src/components/deepagent/panel-goal-contract.test.ts b/packages/app/src/components/deepagent/panel-goal-contract.test.ts index f1458e6f..420ae8ee 100644 --- a/packages/app/src/components/deepagent/panel-goal-contract.test.ts +++ b/packages/app/src/components/deepagent/panel-goal-contract.test.ts @@ -116,9 +116,9 @@ describe("Goal Loop route contract (§D)", () => { describe("capabilities gating", () => { test("fetchCapabilities GETs /global/capabilities and reads the feature flags", async () => { const calls: Recorded[] = [] - const caps = await fetchCapabilities(client(calls, { features: { expertPanel: true, goalLoop: false } })) + const caps = await fetchCapabilities(client(calls, { features: { expertPanel: true, goalLoop: false, wiki: true } })) expect(calls).toEqual([{ method: "GET", url: "/global/capabilities" }]) - expect(caps).toEqual({ expertPanel: true, goalLoop: false }) + expect(caps).toEqual({ expertPanel: true, goalLoop: false, wiki: true }) }) test("fetchCapabilities treats a server that omits the fields as disabled", async () => { @@ -126,8 +126,9 @@ describe("capabilities gating", () => { expect(await fetchCapabilities(client(calls, { features: {} }))).toEqual({ expertPanel: false, goalLoop: false, + wiki: false, }) // and a server with no features object at all - expect(await fetchCapabilities(client(calls, {}))).toEqual({ expertPanel: false, goalLoop: false }) + expect(await fetchCapabilities(client(calls, {}))).toEqual({ expertPanel: false, goalLoop: false, wiki: false }) }) }) diff --git a/packages/app/src/components/deepagent/panel-goal.api.ts b/packages/app/src/components/deepagent/panel-goal.api.ts index af4e1416..65c1f34a 100644 --- a/packages/app/src/components/deepagent/panel-goal.api.ts +++ b/packages/app/src/components/deepagent/panel-goal.api.ts @@ -51,6 +51,7 @@ export type PanelGoalClient = RawSdkClient export type DeepAgentCapabilities = { expertPanel: boolean goalLoop: boolean + wiki: boolean } /** @@ -66,6 +67,7 @@ export const fetchCapabilities = async (client: PanelGoalClient): Promise = { + knowledge: "knowledge", + strategy: "knowledge", + methodology: "knowledge", + memory: "memory", + code_symbol: "code", +} +const groupOf = (type: string): "knowledge" | "memory" | "code" | "document" => + (TYPE_GROUP[type] as "knowledge" | "memory" | "code") ?? "document" + +const GROUP_ORDER: ReadonlyArray<"knowledge" | "memory" | "document" | "code"> = [ + "knowledge", + "memory", + "document", + "code", +] + +export const DialogWiki: Component<{ client: WikiClient }> = (props) => { + const language = useLanguage() + const [query, setQuery] = createSignal("") + const [selectedId, setSelectedId] = createSignal<{ docId: string; scope: string } | undefined>(undefined) + const [editing, setEditing] = createSignal(false) + const [editBody, setEditBody] = createSignal("") + const [busy, setBusy] = createSignal(false) + + // The full page list (project projection). Refetched after a governed edit bumps a version. + const [pages, { refetch: refetchPages }] = createResource(async () => listWikiPages(props.client)) + + // Search hits (only when the query is non-empty) — the FTS index over the projection. + const [hits] = createResource( + () => query().trim() || undefined, + async (text) => searchWiki(props.client, { text }), + ) + + // The set of doc ids matching the current search (used to filter the list). Empty query ⇒ show all. + const matchIds = createMemo(() => { + const q = query().trim() + if (!q) return undefined + return new Set((hits() ?? []).map((h) => h.docId)) + }) + + const visiblePages = createMemo(() => { + const all = pages() ?? [] + const ids = matchIds() + return ids ? all.filter((p) => ids.has(p.docId)) : all + }) + + const groups = createMemo(() => { + const byGroup = new Map() + for (const p of visiblePages()) { + const g = groupOf(p.type) + const list = byGroup.get(g) ?? [] + list.push(p) + byGroup.set(g, list) + } + return GROUP_ORDER.map((g) => ({ group: g, items: byGroup.get(g) ?? [] })).filter((x) => x.items.length > 0) + }) + + // The rendered detail page for the current selection. + const [page, { refetch: refetchPage }] = createResource( + () => selectedId(), + async (sel) => getWikiPage(props.client, sel.docId, sel.scope), + ) + + const select = (p: WikiPageSummary) => { + setEditing(false) + setSelectedId({ docId: p.docId, scope: p.scope }) + } + + const startEdit = (pg: WikiPage) => { + setEditBody(pg.markdown) + setEditing(true) + } + + const saveEdit = async (pg: WikiPage) => { + const sel = selectedId() + if (!sel || busy()) return + setBusy(true) + try { + const updated = await editWikiKnowledge(props.client, { + docId: pg.docId, + scope: sel.scope, + body: editBody(), + // The human provenance identity. The desktop app is single-user; the server stamps this id + // on the new version and the real evidence-gate requires it to be present. + editor: { id: "desktop-user" }, + }) + if (updated) { + setEditing(false) + await refetchPage() + await refetchPages() + showToast({ variant: "success", title: language.t("wiki.edit.saved") }) + } + } catch (error) { + showToast({ + variant: "error", + title: language.t("wiki.edit.failed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setBusy(false) + } + } + + return ( + +
+
+

{language.t("wiki.description")}

+ + {/* Search over the FTS projection. */} +
+ + setQuery(e.currentTarget.value)} + placeholder={language.t("wiki.search")} + aria-label={language.t("wiki.search")} + class="min-w-0 flex-1 bg-transparent text-13-regular text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + /> +
+ + {/* Two-column list + detail. */} +
+ language.t(`wiki.type.${groupOf(t)}`)} + /> + setEditing(false)} + onSave={saveEdit} + /> +
+
+
+
+ ) +} + +const WikiList: Component<{ + groups: { group: string; items: WikiPageSummary[] }[] + loading: boolean + empty: string + selectedId: string | undefined + onSelect: (p: WikiPageSummary) => void + typeLabel: (type: string) => string +}> = (props) => { + const language = useLanguage() + return ( +
+ {language.t("wiki.loading")}
} + > + 0} + fallback={
{props.empty}
} + > + + {(group) => ( +
+ + {props.typeLabel(group.items[0]!.type)} + + + {(p) => ( + + )} + +
+ )} +
+
+ + + ) +} + +const WikiDetail: Component<{ + page: WikiPage | undefined + loading: boolean + hasSelection: boolean + editing: boolean + editBody: string + busy: boolean + onEditBody: (v: string) => void + onStartEdit: (pg: WikiPage) => void + onCancelEdit: () => void + onSave: (pg: WikiPage) => void +}> = (props) => { + const language = useLanguage() + return ( +
+ {language.t("wiki.selectPrompt")}
} + > + {language.t("wiki.loading")}} + > + {(pg) => ( + <> +
+ {pg().title} + + {language.t("wiki.version", { version: pg().version })} + + + + + + + {language.t("wiki.readOnly")} + + +
+ + + + + + } + > +