diff --git a/README.md b/README.md index da24d381..98d360e1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ 简体中文

+

Desktop v1.3

+ --- DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane so the agent behaves less like a one-shot chat and more like a teammate that remembers your project, sharpens vague asks, and goes deep on hard problems. @@ -45,6 +47,24 @@ The features below start from a real need — something a plain coding agent, op **What DeepAgent does:** Two scenario modes on the composer. **Direct** sends your prompt as-is — you own the wording. **Intelligence** refines a rough ask into a sharper prompt, surfaces a draft plan and decision suggestions, and waits for your confirmation before it automates anything. You decide how much the agent shapes the request. +### Choose how much the agent drives — and who writes the plan + +**The need:** Sometimes you want the agent to just take a request and run; sometimes you want to steer it with a plan you control; sometimes you've already written the plan and just want it executed faithfully. + +**What DeepAgent does:** Three collaboration modes on the composer, picked from a single selector. **Auto** — the agent sets the objective, designs and plans as needed, and executes to completion. **Loop** — you describe the goal, the agent writes a `goal+plan.md` you can edit, then a supervised loop drives it to completion (plan → execute → verify per tick, with hard budget/step ceilings and objective completion checks). **Design** — you author `goal+plan.md` yourself and the agent executes your plan faithfully without redefining the goal. Orthogonal to mode, a permission control offers three presets — **Read-only**, **Request approval** (default), **Full access** — so autonomy and approval are separate, explicit choices. + +### Get a second opinion before high-risk decisions + +**The need:** Some decisions — a breaking migration, a security-sensitive change, an architecture call — deserve more than one confident pass agreeing with itself. + +**What DeepAgent does:** Convene an **Expert Panel** from the composer. Differentiated expert lenses (correctness, security, performance, architecture, repro) review the same frozen question independently, debate anonymously, and a deterministic (non-LLM) arbiter aggregates a verdict — with minority opinions preserved and a fail-closed bias toward escalating to you when the panel can't safely agree. + +### Read and govern what the agent knows + +**The need:** Persistent memory is only trustworthy if you can see it and correct it. + +**What DeepAgent does:** A **Repo & Wiki** view projects the four graphs into human-readable pages — browse and full-text-search the agent's knowledge, follow docs↔code cross-links, and edit governable Knowledge/Memory pages through the same evidence-gate the agent uses (Documents and Code stay read-only). A separate governance view lists learned facts grouped by project and global scope, so you approve what becomes durable. + ### Go deep on genuinely hard problems **The need:** Complex work — an architecture decision, a tricky migration, a subtle bug — needs more than a single confident pass. It needs research, a second opinion, and someone actively trying to poke holes. diff --git a/README.zh.md b/README.zh.md index 9d1618ab..0a17532a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,6 +13,8 @@ 简体中文

+

桌面版 v1.3

+ --- DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体。它保留了 [opencode](https://github.com/sst/opencode) 的运行时基座,并在其上叠加了一层控制平面——让智能体不再像一次性的对话工具,而更像一位记得住你项目、会替你打磨模糊需求、能对硬骨头深挖到底的队友。 @@ -45,6 +47,24 @@ DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体 **DeepAgent 的做法:** 输入框上有两种情景模式。**直接**模式原样发送你的提示——措辞由你做主。**智能**模式会把粗糙的想法打磨成更精准的提示,给出草拟的方案和决策建议,并在自动执行任何操作前等你确认。智能体替你塑形到什么程度,由你决定。 +### 选择智能体驱动的方式——以及由谁来写计划 + +**需求:** 有时你希望智能体拿到需求就自己跑;有时你想用一份由自己掌控的计划来引导它;有时你已经把计划写好了,只需要它忠实执行。 + +**DeepAgent 的做法:** 输入框上的协作模式选择器提供三档。**自动** ——智能体自行定目标、做计划、执行到完成。**目标** ——你说明需求,智能体生成一份你可以编辑的 `goal+plan.md`,再由监督循环驱动执行(计划→执行→验证逐步推进,有硬性预算/步数上限和客观完成判据)。**设计** ——你自己写好 `goal+plan.md`,智能体读取并忠实执行你的方案,不会重新定义目标。与协作模式正交,权限控制提供三个预设——**只读**、**请求批准**(默认)、**完全访问**——自主程度与审批方式是两个独立的、明确的选项。 + +### 在高风险决策前听一次会诊 + +**需求:** 有些决策——一次破坏性迁移、一个安全敏感的改动、一个架构抉择——值得不止一次自信的作答自我认同。 + +**DeepAgent 的做法:** 在输入框旁召集**专家团**。差异化的专家视角(正确性、安全、性能、架构、可复现)对同一个冻结问题各自独立审阅,匿名辩论,再由一个确定性(非 LLM)的仲裁者聚合裁定——保留少数派意见,并在专家团无法安全达成一致时偏向将决策权升级给你。 + +### 读懂并治理智能体所知道的 + +**需求:** 持久记忆只有在你能看见、能纠错的情况下才值得信任。 + +**DeepAgent 的做法:** **仓库与百科**视图把四张图投影成人类可读的页面——浏览并全文检索智能体的知识,跟随文档↔代码的交叉链接,并通过与智能体相同的证据门编辑可治理的知识/记忆页面(文档与代码页面只读)。单独的知识治理视图按项目和全局分组列出已学事实,由你审批哪些成为永久知识。 + ### 对真正的难题深挖到底 **需求:** 复杂的工作——一个架构决策、一次棘手的迁移、一个隐蔽的 bug——需要的不止一次自信的单程作答。它需要调研、需要第二意见、需要有人主动来挑刺。 diff --git a/flake.nix b/flake.nix index a8f46676..1930ef22 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,17 @@ }; }); + apps = forEachSystem (pkgs: { + update-hashes = { + type = "app"; + program = let + script = pkgs.writeShellScriptBin "update-hashes" '' + exec ${pkgs.python3}/bin/python3 ${./nix/scripts/update-hashes.py} "$@" + ''; + in "${script}/bin/update-hashes"; + }; + }); + overlays = { default = final: _prev: @@ -63,7 +74,8 @@ deepagent-code-desktop = pkgs.callPackage ./nix/desktop.nix { inherit deepagent-code; }; - # Updater derivation with fakeHash - build fails and reveals correct hash + # Build with fakeHash to reveal the correct hash. + # Used by: nix run .#update-hashes node_modules_updater = node_modules.override { hash = pkgs.lib.fakeHash; }; diff --git a/nix/hashes.json b/nix/hashes.json index 17fbfc4d..18bc59a9 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-vB4imi5LtCxnDuOaJFQh+asK51RUDCnIVCkIzBQN12o=", + "x86_64-linux": "sha256-2cnaDO1erE8kXD6iZtzAqd0hoLLl3IV1hwqXI9yoUhw=", "aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=", "aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=", "x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0=" diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 4d1c1516..15c37c72 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -3,6 +3,8 @@ stdenvNoCC, bun, rev ? "dirty", + # Auto-managed by nix run .#update-hashes. Do not edit manually. + # CI regenerates when bun.lock changes. hash ? (lib.pipe ./hashes.json [ builtins.readFile diff --git a/nix/scripts/update-hashes.py b/nix/scripts/update-hashes.py new file mode 100644 index 00000000..e3a6c6a9 --- /dev/null +++ b/nix/scripts/update-hashes.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Auto-update hashes.json by building with fakeHash and extracting the correct hash. + +Usage: + nix run .#update-hashes # update current platform + nix run .#update-hashes -- --check # CI: verify hashes are current + nix/scripts/update-hashes.py --root /path # run directly with explicit root +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def get_current_platform(): + machine = os.uname().machine + system = os.uname().sysname.lower() + arch_map = {"x86_64": "x86_64", "aarch64": "aarch64"} + os_map = {"linux": "linux", "darwin": "darwin"} + arch = arch_map.get(machine, machine) + os_name = os_map.get(system, system) + return f"{arch}-{os_name}" + + +def extract_hash(stderr: str) -> str | None: + m = re.search(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", stderr) + return m.group(1) if m else None + + +def parse_args(): + root = None + check = False + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--root" and i + 1 < len(args): + root = Path(args[i + 1]) + i += 2 + elif args[i] == "--check": + check = True + i += 1 + else: + print(f"Unknown argument: {args[i]}", file=sys.stderr) + sys.exit(1) + return root, check + + +def ensure_repo_root(root: Path, hashes_path: Path): + if not hashes_path.exists(): + print(f"ERROR: {hashes_path} not found", file=sys.stderr) + print(f"Is {root} the deepagent-code repo root?", file=sys.stderr) + print("Use --root PATH to specify the repo root", file=sys.stderr) + sys.exit(1) + + +def build_and_extract(root: Path): + result = subprocess.run( + ["nix", "build", ".#node_modules_updater", "--no-link", "--print-build-logs"], + capture_output=True, text=True, cwd=root, + ) + combined = (result.stdout or "") + (result.stderr or "") + return extract_hash(combined) + + +def main(): + arg_root, check = parse_args() + root = arg_root or Path(os.getcwd()) + hashes_path = root / "nix" / "hashes.json" + ensure_repo_root(root, hashes_path) + + platform = get_current_platform() + + if check: + data = json.loads(hashes_path.read_text()) + stored = data["nodeModules"].get(platform) + if not stored: + print(f"ERROR: no hash stored for platform {platform}", file=sys.stderr) + sys.exit(1) + actual = build_and_extract(root) + if not actual: + print("ERROR: could not extract hash from build", file=sys.stderr) + sys.exit(2) + if stored != actual: + print(f"HASH MISMATCH: stored={stored[:30]}... actual={actual[:30]}...", file=sys.stderr) + print("Run: nix run .#update-hashes", file=sys.stderr) + sys.exit(1) + print(f"OK: hash matches for {platform}") + return + + print(f"Platform: {platform}") + print("Building node_modules_updater to compute correct hash ...") + sys.stdout.flush() + + correct = build_and_extract(root) + if not correct: + print("ERROR: could not extract hash from build output", file=sys.stderr) + sys.exit(1) + + print(f"Correct hash: {correct}") + + data = json.loads(hashes_path.read_text()) + old = data["nodeModules"].get(platform, "(missing)") + data["nodeModules"][platform] = correct + hashes_path.write_text(json.dumps(data, indent=2) + "\n") + + print(f"Updated {platform}: {old} → {correct}") + print() + print("Done. You can now run: nix build .#deepagent-code") + + +if __name__ == "__main__": + main() diff --git a/packages/app/src/components/deepagent-settings-ux.test.ts b/packages/app/src/components/deepagent-settings-ux.test.ts index 8c2d60b5..f2e218fc 100644 --- a/packages/app/src/components/deepagent-settings-ux.test.ts +++ b/packages/app/src/components/deepagent-settings-ux.test.ts @@ -35,9 +35,11 @@ describe("DeepAgent settings UX", () => { // …it is a composer control next to the agent selector, backed by directory-level auto-accept. expect(composer).toContain("ApprovalControl") expect(control).toContain('"data-action": "prompt-approval"') - expect(control).toContain("toggleAutoAcceptDirectory") + // Tri-state Read-Only / Request / Full-Access selector backed by the permission context. + expect(control).toContain("setDirectoryApprovalMode") expect(control).toContain("composer.approval.request") - expect(control).toContain("composer.approval.auto") + expect(control).toContain("composer.approval.readOnly") + expect(control).toContain("composer.approval.fullAccess") }) test("routes the legacy settings dialog import to the unified settings page", async () => { diff --git a/packages/app/src/components/deepagent/approval-control.tsx b/packages/app/src/components/deepagent/approval-control.tsx index 5654f8ba..cabd2b57 100644 --- a/packages/app/src/components/deepagent/approval-control.tsx +++ b/packages/app/src/components/deepagent/approval-control.tsx @@ -1,39 +1,49 @@ import { createMemo, type JSX } from "solid-js" import { Select } from "@deepagent-code/ui/select" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" +import { usePermission, type DirectoryApprovalMode } from "@/context/permission" /** * Approval-mode control for the composer toolbar, next to the agent (build/plan) selector. * - * Mirrors Codex's approval selector UX, simplified to two options: the button shows the CURRENT mode - * ("Request approval" by default, "Auto-approve" when armed); clicking opens a small picker to switch. - * The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the existing - * permission context (isAutoAcceptingDirectory / toggleAutoAcceptDirectory) — the same state the old - * settings toggle drove, now surfaced where the user acts. + * Mirrors Codex's approval selector UX: it collapses the approval×sandbox space into three named + * presets. The button shows the CURRENT mode; clicking opens a small picker to switch: + * - "Read-Only" — the agent may read/search but write/edit/bash requests are auto-rejected. + * - "Request" (default) — normal permission flow; the agent asks before write/execute. + * - "Full-Access" — auto-approve everything (the old directory-level auto-accept). + * + * The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the + * permission context tri-state (directoryApprovalMode / setDirectoryApprovalMode). Full-Access maps + * onto the existing isAutoAcceptingDirectory / toggleAutoAcceptDirectory state the old settings + * toggle drove, now surfaced where the user acts. */ -type ApprovalMode = "request" | "auto" +type ApprovalMode = DirectoryApprovalMode export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.CSSProperties; onAfter?: () => void }) { const language = useLanguage() const permission = usePermission() - const auto = createMemo(() => (props.directory ? permission.isAutoAcceptingDirectory(props.directory) : false)) - const current = createMemo(() => (auto() ? "auto" : "request")) + const current = createMemo(() => + props.directory ? permission.directoryApprovalMode(props.directory) : "request", + ) - const options: ApprovalMode[] = ["request", "auto"] - const label = (mode: ApprovalMode) => - mode === "auto" - ? language.t("composer.approval.auto") - : language.t("composer.approval.request") + const options: ApprovalMode[] = ["read-only", "request", "full-access"] + const label = (mode: ApprovalMode) => { + switch (mode) { + case "read-only": + return language.t("composer.approval.readOnly") + case "full-access": + return language.t("composer.approval.fullAccess") + default: + return language.t("composer.approval.request") + } + } const onSelect = (mode: ApprovalMode | undefined) => { if (!mode || !props.directory) return - const isAuto = mode === "auto" - if (isAuto === auto()) return - // toggleAutoAcceptDirectory flips the directory-level state; only call it when the target differs. - permission.toggleAutoAcceptDirectory(props.directory) + if (mode === current()) return + permission.setDirectoryApprovalMode(props.directory, mode) props.onAfter?.() } @@ -46,7 +56,7 @@ export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.C value={(o) => o} label={label} onSelect={onSelect} - class="capitalize max-w-[160px] text-text-base" + class="max-w-[180px] text-text-base" valueClass="truncate text-13-regular text-text-base" triggerStyle={props.triggerStyle} triggerProps={{ "data-action": "prompt-approval" }} 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..9a33b798 --- /dev/null +++ b/packages/app/src/components/deepagent/goal-start-button.tsx @@ -0,0 +1,94 @@ +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). + */ +// The raw SDK client THROWS the parsed error BODY on a non-2xx response — which is a plain object, +// not an Error. So `String(err)` yields "[object Object]". Extract a human-readable message from the +// shapes the server returns: a 400 DeepAgentPromotionError `{message}`, or a 500 `{data:{message}}`. +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message + if (err && typeof err === "object") { + const o = err as { message?: unknown; data?: { message?: unknown } } + if (typeof o.message === "string") return o.message + if (o.data && typeof o.data.message === "string") return o.data.message + } + return String(err) +} + +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) { + showToast({ title: language.t("goal.start.failed"), description: errorMessage(err) }) + } finally { + setBusy(false) + } + } + + return ( + +
+ + {language.t("goal.start.hint")} + +
+
+ ) +} diff --git a/packages/app/src/components/deepagent/goal-status-bar.tsx b/packages/app/src/components/deepagent/goal-status-bar.tsx index f8838a6d..a243bdf9 100644 --- a/packages/app/src/components/deepagent/goal-status-bar.tsx +++ b/packages/app/src/components/deepagent/goal-status-bar.tsx @@ -3,6 +3,7 @@ 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 { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-goal.api" /** @@ -12,13 +13,13 @@ import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-g * while the background loop ticks and after a terminal phase (until the user starts a new goal). */ -const PHASE_LABEL: Record = { - running: "Running", - paused: "Paused", - done: "Complete", - needs_human: "Needs you", - rolled_back: "Rolled back", - stopped: "Stopped", +const PHASE_LABEL_KEY: Record = { + running: "composer.goal.phase.running", + paused: "composer.goal.phase.paused", + done: "composer.goal.phase.done", + needs_human: "composer.goal.phase.needsHuman", + rolled_back: "composer.goal.phase.rolledBack", + stopped: "composer.goal.phase.stopped", } const PHASE_ICON: Record[0]["name"]> = { @@ -36,6 +37,7 @@ const isTerminal = (phase: string) => export function GoalStatusBar(props: { sessionID: string }) { const serverSync = useServerSync() const sdk = useSDK() + const language = useLanguage() const [busy, setBusy] = createSignal(false) const goal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined)) @@ -74,9 +76,14 @@ export function GoalStatusBar(props: { sessionID: string }) { class="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-raised border border-border-subtle text-13-regular" > - {PHASE_LABEL[g().phase] ?? g().phase} + + {PHASE_LABEL_KEY[g().phase] ? language.t(PHASE_LABEL_KEY[g().phase] as never) : g().phase} + - {ticks()} {ticks() === 1 ? "tick" : "ticks"} · {tokens().toLocaleString()} tokens + {language.t(ticks() === 1 ? "composer.goal.budget.one" : "composer.goal.budget.other", { + ticks: ticks(), + tokens: tokens().toLocaleString(), + })} 0}> — {g().gaps[0]} @@ -84,21 +91,21 @@ export function GoalStatusBar(props: { sessionID: string }) {
- - diff --git a/packages/app/src/components/deepagent/mode-selector.tsx b/packages/app/src/components/deepagent/mode-selector.tsx new file mode 100644 index 00000000..a1998dda --- /dev/null +++ b/packages/app/src/components/deepagent/mode-selector.tsx @@ -0,0 +1,160 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { type Component, type ComponentProps, createMemo, type JSX, Show, type ValidComponent } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocal } from "@/context/local" +import { useLanguage } from "@/context/language" +import { List, type ListRef } from "@deepagent-code/ui/list" + +/** + * Composer collaboration-mode selector. + * + * Replaces the old label-only { - local.agent.set(value) - restoreFocus() + + onClose={restoreFocus} + > + {modeLabel(local.agent.current()?.name)} + +
diff --git a/packages/app/src/components/review/dialog-review.api.ts b/packages/app/src/components/review/dialog-review.api.ts index 3dc44596..3f293b78 100644 --- a/packages/app/src/components/review/dialog-review.api.ts +++ b/packages/app/src/components/review/dialog-review.api.ts @@ -10,6 +10,9 @@ export type KnowledgeItem = { evidence_strength: "strong" | "medium" | "weak" | "none" evidence_refs: string[] approval_status: "pending" | "approved" | "rejected" + // Storage scope, for grouping by project vs global: "durable" (global) or + // "durable:project:". Absent from older servers → treated as global. + scope?: string } type RawSdkClient = { diff --git a/packages/app/src/components/review/dialog-review.tsx b/packages/app/src/components/review/dialog-review.tsx index ec85e370..c3c8a165 100644 --- a/packages/app/src/components/review/dialog-review.tsx +++ b/packages/app/src/components/review/dialog-review.tsx @@ -150,29 +150,30 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { const pending = createMemo(() => (items() ?? []).filter((i) => i.approval_status === "pending" && matchesQuery(i))) const approved = createMemo(() => (items() ?? []).filter((i) => i.approval_status === "approved" && matchesQuery(i))) - // Stable display order for the type boxes. Any unknown type falls back into "other". - const TYPE_ORDER = ["memory", "failure_dossier", "knowledge", "strategy", "methodology", "skill"] as const - const typeLabel = (type: string) => - (TYPE_ORDER as readonly string[]).includes(type) - ? language.t(`review.type.${type}`) - : language.t("review.type.other") + // Group by SCOPE, not by type: the governance view shows the user's learned facts split into + // "this project" vs "global". A doc scoped `durable:project:` is project-local; anything else + // (`durable`, or legacy untagged) is global. (Skills + domain-pack seed docs are already excluded + // server-side, so every item here is a human-readable, user-learned fact.) + const scopeOf = (item: KnowledgeItem): "project" | "global" => + item.scope?.startsWith("durable:project:") ? "project" : "global" + const scopeLabel = (scope: string) => + scope === "project" ? language.t("review.scope.project") : language.t("review.scope.global") - // Group pending items by type, preserving TYPE_ORDER; trailing "other" bucket for unknown types. + // Project bucket first (most specific to what the user is working on), then global. + const SCOPE_ORDER = ["project", "global"] as const const pendingGroups = createMemo(() => { - const byType = new Map() + const byScope = new Map() for (const item of pending()) { - const key = (TYPE_ORDER as readonly string[]).includes(item.type) ? item.type : "other" - const list = byType.get(key) + const key = scopeOf(item) + const list = byScope.get(key) if (list) list.push(item) - else byType.set(key, [item]) + else byScope.set(key, [item]) } - const ordered: Array<{ type: string; items: KnowledgeItem[] }> = [] - for (const type of TYPE_ORDER) { - const list = byType.get(type) - if (list?.length) ordered.push({ type, items: list }) + const ordered: Array<{ scope: string; items: KnowledgeItem[] }> = [] + for (const scope of SCOPE_ORDER) { + const list = byScope.get(scope) + if (list?.length) ordered.push({ scope, items: list }) } - const other = byType.get("other") - if (other?.length) ordered.push({ type: "other", items: other }) return ordered }) @@ -526,7 +527,7 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { } > - {(group) => GroupBox({ boxKey: group.type, label: typeLabel(group.type), items: group.items })} + {(group) => GroupBox({ boxKey: group.scope, label: scopeLabel(group.scope), items: group.items })} 0}> {GroupBox({ boxKey: "approved", label: language.t("review.status.approved"), items: approved() })} diff --git a/packages/app/src/components/wiki/dialog-wiki.tsx b/packages/app/src/components/wiki/dialog-wiki.tsx new file mode 100644 index 00000000..3d9170b8 --- /dev/null +++ b/packages/app/src/components/wiki/dialog-wiki.tsx @@ -0,0 +1,350 @@ +import { Component, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { Dialog } from "@deepagent-code/ui/v2/dialog-v2" +import { Button } from "@deepagent-code/ui/button" +import { Icon } from "@deepagent-code/ui/icon" +import { Markdown } from "@deepagent-code/ui/markdown" +import { useLanguage } from "@/context/language" +import { showToast } from "@/utils/toast" +import { + listWikiPages, + getWikiPage, + searchWiki, + editWikiKnowledge, + type WikiPageSummary, + type WikiPage, + type WikiClient, +} from "./wiki.api" +import "../settings-v2/settings-v2.css" + +// Re-export the pure client API (UI-import-free, for the route-contract test) for back-compat. +export { + listWikiPages, + getWikiPage, + searchWiki, + editWikiKnowledge, + type WikiPageSummary, + type WikiPage, + type WikiClient, +} from "./wiki.api" + +// §B.2 governance grouping: two governable (Knowledge/Memory), two monitor-only (Document/Code). +const TYPE_GROUP: Record = { + 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")} + + +
+ + + + + + } + > +