From 4775c5483a45e647d303cd2093e3b897abd4da61 Mon Sep 17 00:00:00 2001 From: spetro511 Date: Mon, 31 Aug 2026 18:09:47 -0400 Subject: [PATCH 1/5] feat: add security workbench foundations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 13 + README.md | 34 +- packages/app/src/context/server.tsx | 5 +- packages/app/src/pages/layout.tsx | 8 + packages/app/src/pages/session.tsx | 68 +- .../src/pages/session/activity-panel.test.ts | 29 + .../app/src/pages/session/activity-panel.tsx | 266 ++++++++ packages/app/src/pages/session/activity.ts | 31 + .../app/src/pages/session/memory-panel.tsx | 254 ++++++++ .../app/src/pages/session/mission-panel.tsx | 314 ++++++++++ .../src/pages/session/session-side-panel.tsx | 307 +++++++-- .../app/src/pages/session/terminal-panel.tsx | 125 ++-- .../app/src/pages/session/topology-panel.tsx | 420 +++++++++++++ packages/app/src/utils/server-health.ts | 14 +- packages/cyberstrike/README.md | 16 +- .../migration.sql | 17 + .../20260831210000_target-notes/migration.sql | 16 + .../migration.sql | 53 ++ .../migration.sql | 1 + packages/cyberstrike/src/cli/cmd/serve.ts | 3 + packages/cyberstrike/src/cli/cmd/web.ts | 7 + packages/cyberstrike/src/cli/network.ts | 6 + packages/cyberstrike/src/config/config.ts | 126 ++-- packages/cyberstrike/src/event/event.sql.ts | 24 + packages/cyberstrike/src/event/index.ts | 255 ++++++++ packages/cyberstrike/src/flag/flag.ts | 12 + packages/cyberstrike/src/id/id.ts | 3 + packages/cyberstrike/src/mcp/catalog.ts | 199 ++++++ packages/cyberstrike/src/mcp/index.ts | 7 +- packages/cyberstrike/src/memory/memory.sql.ts | 35 ++ packages/cyberstrike/src/memory/reflection.ts | 84 +++ packages/cyberstrike/src/memory/store.ts | 324 ++++++++++ packages/cyberstrike/src/project/bootstrap.ts | 4 + packages/cyberstrike/src/server/auth.ts | 69 +++ .../src/server/routes/event-log.ts | 82 +++ packages/cyberstrike/src/server/routes/mcp.ts | 20 + .../cyberstrike/src/server/routes/memory.ts | 155 +++++ .../cyberstrike/src/server/routes/system.ts | 26 + .../cyberstrike/src/server/routes/topology.ts | 128 ++++ packages/cyberstrike/src/server/server.ts | 52 +- packages/cyberstrike/src/session/prompt.ts | 3 + packages/cyberstrike/src/storage/schema.ts | 3 + .../cyberstrike/src/system/capabilities.ts | 113 ++++ packages/cyberstrike/src/tool/host-facts.ts | 31 + .../cyberstrike/src/tool/lazy-registry.ts | 42 +- packages/cyberstrike/src/tool/memory.ts | 87 +-- packages/cyberstrike/src/tool/registry.ts | 2 + packages/cyberstrike/src/topology/index.ts | 196 ++++++ packages/cyberstrike/src/topology/note.ts | 178 ++++++ .../cyberstrike/src/topology/topology.sql.ts | 24 + .../cyberstrike/test/config/config.test.ts | 70 +++ packages/cyberstrike/test/event/event.test.ts | 131 ++++ packages/cyberstrike/test/mcp/catalog.test.ts | 30 + .../test/memory/reflection.test.ts | 62 ++ .../cyberstrike/test/memory/store.test.ts | 130 ++++ packages/cyberstrike/test/server/auth.test.ts | 71 +++ .../test/system/capabilities.test.ts | 11 + .../cyberstrike/test/topology/note.test.ts | 34 + .../test/topology/topology.test.ts | 122 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 523 ++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 583 ++++++++++++++++++ 61 files changed, 5771 insertions(+), 287 deletions(-) create mode 100644 packages/app/src/pages/session/activity-panel.test.ts create mode 100644 packages/app/src/pages/session/activity-panel.tsx create mode 100644 packages/app/src/pages/session/activity.ts create mode 100644 packages/app/src/pages/session/memory-panel.tsx create mode 100644 packages/app/src/pages/session/mission-panel.tsx create mode 100644 packages/app/src/pages/session/topology-panel.tsx create mode 100644 packages/cyberstrike/migration/20260831190000_engagement-events/migration.sql create mode 100644 packages/cyberstrike/migration/20260831210000_target-notes/migration.sql create mode 100644 packages/cyberstrike/migration/20260831220000_structured-memory/migration.sql create mode 100644 packages/cyberstrike/migration/20260831220500_memory-metadata/migration.sql create mode 100644 packages/cyberstrike/src/event/event.sql.ts create mode 100644 packages/cyberstrike/src/event/index.ts create mode 100644 packages/cyberstrike/src/mcp/catalog.ts create mode 100644 packages/cyberstrike/src/memory/memory.sql.ts create mode 100644 packages/cyberstrike/src/memory/reflection.ts create mode 100644 packages/cyberstrike/src/memory/store.ts create mode 100644 packages/cyberstrike/src/server/auth.ts create mode 100644 packages/cyberstrike/src/server/routes/event-log.ts create mode 100644 packages/cyberstrike/src/server/routes/memory.ts create mode 100644 packages/cyberstrike/src/server/routes/system.ts create mode 100644 packages/cyberstrike/src/server/routes/topology.ts create mode 100644 packages/cyberstrike/src/system/capabilities.ts create mode 100644 packages/cyberstrike/src/tool/host-facts.ts create mode 100644 packages/cyberstrike/src/topology/index.ts create mode 100644 packages/cyberstrike/src/topology/note.ts create mode 100644 packages/cyberstrike/src/topology/topology.sql.ts create mode 100644 packages/cyberstrike/test/event/event.test.ts create mode 100644 packages/cyberstrike/test/mcp/catalog.test.ts create mode 100644 packages/cyberstrike/test/memory/reflection.test.ts create mode 100644 packages/cyberstrike/test/memory/store.test.ts create mode 100644 packages/cyberstrike/test/server/auth.test.ts create mode 100644 packages/cyberstrike/test/system/capabilities.test.ts create mode 100644 packages/cyberstrike/test/topology/note.test.ts create mode 100644 packages/cyberstrike/test/topology/topology.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd2b604ff..3af7d8a5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/), versions follow ## [Unreleased] +### Added + +- Durable, redacted engagement event log with live SSE, correlation IDs, activity timeline/lanes, filters, and JSONL export +- Mission posture and topology panels for methodology coverage, validation, attack chains, agents, assets, hosts, endpoints, findings, and target notes +- Structured project/session memory with FTS5, provenance, trust, confidence, secret redaction, invalidation, failure reflection, and evaluation-gated procedural promotion +- Server-enforced read-only observer role and typed local execution-plane capability inventory +- Version-pinned MCP catalog with manual/optional install states and three additional owner-maintained integrations +- Safe recovery startup with `--safe` and namespaced extension configuration + +### Changed + +- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction + ## [1.1.16] — 2026-08-08 ### Added diff --git a/README.md b/README.md index f25d9ee208..2e06048bdc 100644 --- a/README.md +++ b/README.md @@ -251,16 +251,21 @@ Browser ──HTTPS──▶ Cloudflare Tunnel ──encrypted──▶ cloudfla ```bash export CYBERSTRIKE_SERVER_PASSWORD=your-secure-password +# Optional API/viewer credential with a strict read-only route allowlist: +export CYBERSTRIKE_OBSERVER_PASSWORD=your-observer-password cyberstrike web # In another terminal: cloudflared tunnel --url http://localhost:4096 run your-tunnel ``` +If user or project configuration prevents startup, run `cyberstrike web --safe` to start recovery mode without those config sources. Managed administrator policy is still enforced. + **Why this is secure:** - **Zero open ports** — CyberStrike binds to `localhost:4096`. `cloudflared` makes an outbound-only connection to Cloudflare's edge. No firewall rules, no port forwarding needed. - **End-to-end encryption** — Browser to Cloudflare edge is TLS. Cloudflare edge to your machine is an encrypted tunnel. No plaintext leaves your network. - **Password-protected API** — Every API request requires Basic Auth. Local requests on `localhost` bypass auth for convenience; remote requests via CF tunnel always require credentials (detects `X-Forwarded-For` / `CF-Connecting-IP`). +- **Read-only observers** — The optional `observer` account can read redacted activity, mission posture, topology, findings, and status, but cannot access configuration, secrets, raw events, PTYs, WebSockets, or mutation routes. - **Your data stays local** — LLM inference runs on your hardware. CyberStrike processes everything locally. The tunnel is just a secure pipe. **What's in the Web UI:** @@ -272,6 +277,10 @@ cloudflared tunnel --url http://localhost:4096 run your-tunnel | **Bolt** | Bolt remote server connection monitoring | | **Vulnerabilities** | Discovered vulns with severity, PoC, and impact | | **Web Context** | Endpoints, roles, credentials, and functions discovered during active sessions | +| **Mission** | Methodology phases, coverage, blockers, attack chains, agents, and safe CTAs | +| **Topology** | Evidence-linked assets, hosts, services, endpoints, identities, and findings | +| **Activity** | Durable Agent/Tool/MCP/Bolt/Browser/PTY lanes with filtering and JSONL export | +| **Memory** | Trust-ranked structured memory, FTS search, redaction, notes, and invalidation | **[app.cyberstrike.io](https://app.cyberstrike.io)** is a hosted static page (no backend, no data storage) for convenience. Or self-host: clone the repo and serve `packages/app/dist/` from your own domain. @@ -308,16 +317,23 @@ Bolt is CyberStrike's remote tool server. Deploy it on any VPS, cloud instance, ### MCP Ecosystem -CyberStrike connects to specialized MCP servers that extend its capabilities — **176+ security tools** across 5 domains: +CyberStrike includes a curated MCP catalog with roughly **724 direct/composite security tools** across 11 default entries: -| Server | Tools | What It Adds | -| ---------------------------------------------------------------------- | ----- | -------------------------------------------------------------------- | -| [cloud-audit-mcp](https://github.com/badchars/cloud-audit-mcp) | 38 | Cloud security audits — 60+ checks across AWS, Azure, GCP | -| [github-security-mcp](https://github.com/badchars/github-security-mcp) | 39 | GitHub security posture — repo, org, actions, secrets, supply chain | -| [cve-mcp](https://github.com/badchars/cve-mcp) | 23 | CVE intelligence — NVD, EPSS, CISA KEV, GitHub Advisory, OSV | -| [osint-mcp](https://github.com/badchars/osint-mcp) | 37 | OSINT recon — Shodan, VirusTotal, SecurityTrails, Censys, DNS, WHOIS | +| Server | Tools | What It Adds | +| ------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | +| [github-security-mcp](https://github.com/badchars/github-security-mcp) | 39 | GitHub org, repo, Actions, secrets, supply chain, and access posture | +| [cve-mcp](https://github.com/badchars/cve-mcp) | 41 | CVE intelligence across 11 vulnerability and exploitability sources | +| [osint-mcp-server](https://github.com/badchars/osint-mcp-server) | 37 | Shodan, VirusTotal, Censys, DNS, WHOIS, certificates, BGP, and archives | +| [cloud-audit-mcp](https://github.com/badchars/cloud-audit-mcp) | 38 | AWS, Azure, and GCP security audits with 60+ checks | +| [hackbrowser-mcp](https://github.com/badchars/hackbrowser-mcp) | 39 | Firefox security browser, isolated roles, traffic replay, active tests | +| [darknet-mcp-server](https://github.com/badchars/darknet-mcp-server) | 66 | Breach, ransomware, Tor, malware, blockchain, and exploit intelligence | +| [dns-security-mcp](https://github.com/badchars/dns-security-mcp) | 103 | DNSSEC, email, hijacking, tunneling, typosquatting, and certificates | +| [supply-chain-mcp-server](https://github.com/badchars/supply-chain-mcp-server) | 7/90 | 7 composite tools orchestrating 90 package and provenance techniques | +| [mcp-security-scanner](https://github.com/badchars/mcp-security-scanner) | 55 | Runtime, source, config, dependency, and OWASP MCP security analysis | +| [steganography-mcp](https://github.com/badchars/steganography-mcp) | 128 | Offline image, audio, video, document, and covert-channel analysis | +| [satellite-mcp](https://github.com/badchars/satellite-mcp) | 171 | Satellite, aviation, maritime, conflict, infrastructure, and GEOINT | -All open source. All installable with `npx`. Plug them into CyberStrike or use them standalone with any MCP-compatible client. +Runnable npm entries are version-pinned. `cloud-audit-mcp` and `hackbrowser-mcp` currently require manual installation from their repositories. The catalog also offers optional wireless-security, LOLBin, and fingerprinting servers. --- @@ -327,7 +343,7 @@ CyberStrike agents have direct access to **56+ tools** without any external depe | Category | Tools | | --------------------- | ----------------------------------------------------------------------------------- | -| **Execution** | Shell (bash), file read/write/edit/patch, directory listing, batch operations | +| **Execution** | Shell, typed host readiness, file read/write/edit/patch, directory listing, batch operations | | **Discovery** | Web fetch, web search, code search, glob, grep, intel gathering | | **Offensive** | HackBrowser, attack script execution, vulnerability reporting & triage | | **Post-Exploitation** | AWS hook, Azure hook, Kubernetes hook, Windows hook, macOS hook, CI/CD pipe, eBPF | diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 9fc3d75b2e..add17758fa 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -2,7 +2,7 @@ import { createSimpleContext } from "@cyberstrike-io/ui/context" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import { useCheckServerHealth } from "@/utils/server-health" +import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http @@ -97,6 +97,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( active: "" as ServerConnection.Key | "", healthy: undefined as boolean | undefined, needsAuth: false, + role: undefined as ServerHealth["role"], }) const healthy = () => state.healthy @@ -161,6 +162,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!alive) return setState("healthy", result.healthy) setState("needsAuth", !!result.needsAuth) + setState("role", result.role) }) .finally(() => { busy = false @@ -246,6 +248,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ready: isReady, healthy, needsAuth, + role: () => state.role, isLocal, get key() { return state.active as ServerConnection.Key diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c22ce98309..07443c9fd2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1949,6 +1949,14 @@ export default function Layout(props: ParentProps) { return (
+ +
+ Read-only observer mode · execution, configuration, secrets, and terminal input are disabled +
+
- ) => string} - responding={ui.responding} - onDecide={decide} - inputRef={(el) => { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - setPromptDockRef={(el) => (promptDock = el)} - /> + + Read-only observer mode +
+ } + > + ) => string} + responding={ui.responding} + onDecide={decide} + inputRef={(el) => { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + setPromptDockRef={(el) => (promptDock = el)} + /> + handoff.terminal.get(params.dir!) ?? []} diff --git a/packages/app/src/pages/session/activity-panel.test.ts b/packages/app/src/pages/session/activity-panel.test.ts new file mode 100644 index 0000000000..68e57c026d --- /dev/null +++ b/packages/app/src/pages/session/activity-panel.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { isActivity, mergeActivity } from "./activity" + +const event = (id: string, time: number, title = id) => ({ + id, + projectID: "project", + sessionID: "session", + type: "session.updated", + source: "agent" as const, + data: { title }, + time, +}) + +describe("activity history", () => { + test("preserves live events that arrive before history", () => { + expect(mergeActivity([event("one", 1)], [event("two", 2)])).toEqual([event("one", 1), event("two", 2)]) + }) + + test("keeps the live version of duplicate events", () => { + expect(mergeActivity([event("one", 1, "old")], [event("one", 1, "new")])).toEqual([ + event("one", 1, "new"), + ]) + }) + + test("ignores SSE heartbeats", () => { + expect(isActivity({})).toBe(false) + expect(isActivity(event("one", 1))).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/activity-panel.tsx b/packages/app/src/pages/session/activity-panel.tsx new file mode 100644 index 0000000000..6d0c80ec18 --- /dev/null +++ b/packages/app/src/pages/session/activity-panel.tsx @@ -0,0 +1,266 @@ +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useParams } from "@solidjs/router" +import { Icon } from "@cyberstrike-io/ui/icon" +import { useSDK } from "@/context/sdk" +import { isActivity, mergeActivity, type Activity, type ActivitySource } from "./activity" + +const sources: Array<{ id: ActivitySource; label: string }> = [ + { id: "agent", label: "Agent" }, + { id: "tool", label: "Tool" }, + { id: "mcp", label: "MCP" }, + { id: "bolt", label: "Bolt" }, + { id: "browser", label: "Browser" }, + { id: "pty", label: "PTY" }, + { id: "finding", label: "Finding" }, + { id: "system", label: "System" }, +] + +const badge = (source: ActivitySource) => { + if (source === "tool") return "bg-surface-accent-base text-text-accent-base" + if (source === "mcp" || source === "bolt") return "bg-surface-info-base text-text-info-base" + if (source === "finding") return "bg-surface-warning-base text-text-warning-base" + if (source === "browser") return "bg-surface-success-base text-text-success-base" + return "bg-surface-base text-text-weak" +} + +const value = (data: Record, key: string) => + typeof data[key] === "string" || typeof data[key] === "number" ? String(data[key]) : "" + +const summary = (event: Activity) => { + const title = value(event.data, "title") + const tool = value(event.data, "tool") + const status = value(event.data, "status") + const name = value(event.data, "name") + const count = value(event.data, "count") + return [tool || name || event.type, status, title, count ? `${count} items` : ""].filter(Boolean).join(" · ") +} + +function ActivityRow(props: { event: Activity }) { + return ( +
+ + + {new Date(props.event.time).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + + {props.event.source.toUpperCase()} + + {summary(props.event)} + +
+        {JSON.stringify(
+          {
+            type: props.event.type,
+            correlationID: props.event.correlationID,
+            parentID: props.event.parentID,
+            ...props.event.data,
+          },
+          null,
+          2,
+        )}
+      
+
+ ) +} + +export function ActivityPanel() { + const params = useParams() + const sdk = useSDK() + const [events, setEvents] = createStore([]) + const [source, setSource] = createSignal("all") + const [search, setSearch] = createSignal("") + const [mode, setMode] = createSignal<"timeline" | "lanes">("timeline") + const [follow, setFollow] = createSignal(true) + const [error, setError] = createSignal("") + let scroll!: HTMLDivElement + + const add = (event: Activity) => { + const index = events.findIndex((item) => item.id === event.id) + if (index !== -1) { + setEvents(index, reconcile(event)) + return + } + setEvents( + produce((draft) => { + draft.push(event) + if (draft.length > 2_000) draft.splice(0, draft.length - 2_000) + }), + ) + } + + const merge = (incoming: Activity[]) => { + setEvents(reconcile(mergeActivity(incoming, [...events]))) + } + + createEffect(() => { + const sessionID = params.id + if (!sessionID) { + setEvents(reconcile([])) + return + } + + const abort = new AbortController() + const client = sdk.createClient({ + directory: sdk.directory, + throwOnError: true, + signal: abort.signal, + }) + setError("") + void client.eventLog + .list({ sessionID, limit: 500 }) + .then((response) => merge(response.data ?? [])) + .catch((cause) => { + if (!abort.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)) + }) + void (async () => { + try { + const response = await client.eventLog.stream( + { sessionID }, + { + onSseError: (cause) => { + if (!abort.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)) + }, + }, + ) + for await (const event of response.stream) { + if (isActivity(event)) add(event) + } + } catch (cause) { + if (!abort.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)) + } + })() + onCleanup(() => abort.abort()) + }) + + const filtered = createMemo(() => { + const query = search().trim().toLowerCase() + return events.filter((event) => { + if (source() !== "all" && event.source !== source()) return false + if (!query) return true + return `${event.type} ${summary(event)} ${event.correlationID ?? ""}`.toLowerCase().includes(query) + }) + }) + + createEffect(() => { + filtered().length + if (!follow() || !scroll) return + requestAnimationFrame(() => scroll.scrollTo({ top: scroll.scrollHeight })) + }) + + const download = () => { + const body = filtered().map((event) => JSON.stringify(event)).join("\n") + const url = URL.createObjectURL(new Blob([body], { type: "application/x-ndjson" })) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `cyberstrike-activity-${params.id ?? "session"}.jsonl` + anchor.click() + URL.revokeObjectURL(url) + } + + return ( +
+
+ + + {(item) => ( + + )} + +
+ setSearch(event.currentTarget.value)} + /> + + + +
+ +
{error()}
+
+
+ 0} + fallback={
No activity yet
} + > + + {(event) => } +
+ } + > +
+ source() === "all" || source() === item.id)}> + {(item) => { + const lane = createMemo(() => filtered().filter((event) => event.source === item.id)) + return ( +
+
+ {item.label} + {lane().length} +
+ {(event) => } +
+ ) + }} +
+
+ + +
+
+ ) +} diff --git a/packages/app/src/pages/session/activity.ts b/packages/app/src/pages/session/activity.ts new file mode 100644 index 0000000000..3d932752c5 --- /dev/null +++ b/packages/app/src/pages/session/activity.ts @@ -0,0 +1,31 @@ +export type ActivitySource = "agent" | "tool" | "mcp" | "bolt" | "browser" | "pty" | "finding" | "system" + +export type Activity = { + id: string + projectID: string + sessionID?: string + type: string + source: ActivitySource + correlationID?: string + parentID?: string + data: Record + time: number +} + +export const isActivity = (event: unknown): event is Activity => { + if (!event || typeof event !== "object") return false + const value = event as Partial + return ( + typeof value.id === "string" && + typeof value.type === "string" && + typeof value.source === "string" && + typeof value.time === "number" && + !!value.data && + typeof value.data === "object" + ) +} + +export const mergeActivity = (history: Activity[], live: Activity[], limit = 2_000) => { + const byID = new Map([...history, ...live].map((event) => [event.id, event])) + return [...byID.values()].sort((a, b) => a.time - b.time).slice(-limit) +} diff --git a/packages/app/src/pages/session/memory-panel.tsx b/packages/app/src/pages/session/memory-panel.tsx new file mode 100644 index 0000000000..dbacbdb7f1 --- /dev/null +++ b/packages/app/src/pages/session/memory-panel.tsx @@ -0,0 +1,254 @@ +import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useParams } from "@solidjs/router" +import type { MemoryListResponse } from "@cyberstrike-io/sdk/v2/client" +import { Icon } from "@cyberstrike-io/ui/icon" +import { useSDK } from "@/context/sdk" + +type Kind = MemoryListResponse[number]["kind"] + +const kinds: Array<{ id: Kind; label: string }> = [ + { id: "working", label: "Working" }, + { id: "episodic", label: "Episodic" }, + { id: "semantic", label: "Semantic" }, + { id: "procedural", label: "Procedural" }, +] + +const trust = (value: MemoryListResponse[number]["trust"]) => { + if (value === "human") return "bg-surface-success-base text-text-success-base" + if (value === "tool") return "bg-surface-info-base text-text-info-base" + if (value === "untrusted") return "bg-surface-critical-base text-text-critical-base" + return "bg-surface-base text-text-weak" +} + +export function MemoryPanel() { + const params = useParams() + const sdk = useSDK() + const [items, setItems] = createStore([]) + const [query, setQuery] = createSignal("") + const [kind, setKind] = createSignal("all") + const [error, setError] = createSignal("") + const [form, setForm] = createStore({ + open: false, + project: false, + kind: "episodic" as Kind, + title: "", + content: "", + saving: false, + }) + + const load = async () => { + const sessionID = params.id + const filter = kind() + try { + const response = query().trim() + ? await sdk.client.memory.search({ + query: query().trim(), + sessionID, + kind: filter === "all" ? undefined : filter, + limit: 100, + }) + : await sdk.client.memory.list({ + sessionID, + kind: filter === "all" ? undefined : filter, + limit: 200, + }) + setItems(reconcile(response.data ?? [])) + setError("") + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } + + createEffect(() => { + params.id + kind() + const search = query() + const timer = setTimeout(load, search ? 250 : 0) + onCleanup(() => clearTimeout(timer)) + }) + + const add = async () => { + const title = form.title.trim() + const content = form.content.trim() + if (!title || !content || form.saving) return + setForm("saving", true) + try { + const response = await sdk.client.memory.create({ + sessionID: form.project ? undefined : params.id, + kind: form.kind, + title, + content, + confidence: 1, + tags: ["human-confirmed"], + }) + if (response.data) setItems((current) => [response.data!, ...current]) + setForm({ + open: false, + project: false, + kind: "episodic", + title: "", + content: "", + saving: false, + }) + setError("") + } catch (cause) { + setForm("saving", false) + setError(cause instanceof Error ? cause.message : String(cause)) + } + } + + const invalidate = async (id: string) => { + try { + await sdk.client.memory.invalidate({ entryID: id }) + setItems(reconcile(items.filter((item) => item.id !== id))) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } + + return ( +
+
+ Persistent memory + +
+
+ + + {(item) => ( + + )} + +
+
+ setQuery(event.currentTarget.value)} + /> +
+ +
+
+ + +
+ setForm("title", event.currentTarget.value)} + /> +