Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ RISKKERNEL_MCP_ALLOWLIST=
RISKKERNEL_MCP_READONLY=
# Seconds a gated tools/call waits for a human decision (default 110).
RISKKERNEL_MCP_APPROVAL_TIMEOUT=110

# Git-native memory: a directory of markdown/YAML the agent reads (you own it).
RISKKERNEL_MEMORY_DIR=./memory
# Embeddings/semantic index — OFF and not implemented in v0.1 (keyword search only).
RISKKERNEL_MEMORY_EMBEDDINGS=false
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
`tool_calls` row. Enabled by `RISKKERNEL_MCP_UPSTREAM`; allowlist/read-only via
`RISKKERNEL_MCP_ALLOWLIST` / `RISKKERNEL_MCP_READONLY`. Point your MCP client at
the gateway and governance is invisible to allowed, approved calls.
- **Git-native memory layer** — a user-owned directory of markdown/YAML/text the
agent reads (`RISKKERNEL_MEMORY_DIR`, default `./memory`). Deterministic
retrieval: list, read, keyword search; markdown frontmatter (`title`/
`description`) surfaced; reads are path-traversal-safe. **No embedding index /
vector DB** (off by default, not implemented in v0.1). Episodic facts (small
key/value) persist in SQLite (migration `00004`). `GET /v1/memory`,
`GET /v1/memory/entry`, `GET`/`PUT /v1/memory/facts`; `riskkernel memory
list/show`; Python SDK `list_memory`/`read_memory`/`list_facts`/`put_fact`.

[Unreleased]: https://github.com/prashar32/riskkernel/commits/main
6 changes: 5 additions & 1 deletion cmd/riskkernel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func main() {
err = runAudit(args)
case "approvals":
err = runApprovals(args)
case "memory":
err = runMemory(args)
case "version", "--version", "-v":
fmt.Println("riskkernel", version.String())
case "help", "--help", "-h":
Expand Down Expand Up @@ -74,6 +76,8 @@ Usage:
riskkernel approvals list List pending human-in-the-loop approvals
riskkernel approvals approve <id> [--reason ...] Approve a pending request
riskkernel approvals deny <id> [--reason ...] Deny a pending request
riskkernel memory list [namespace] List git-native memory entries
riskkernel memory show <name> [namespace] Print a memory file
riskkernel version Print build identity
riskkernel help Show this help

Expand Down Expand Up @@ -117,7 +121,7 @@ func runServe(_ []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Approvals, deps.MCP, deps.Log)
srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Approvals, deps.MCP, deps.Memory, deps.Log)
addr := fmt.Sprintf(":%d", cfg.Port)
return srv.Serve(ctx, addr)
}
Expand Down
67 changes: 67 additions & 0 deletions cmd/riskkernel/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"fmt"
"os"
"text/tabwriter"

"github.com/prashar32/riskkernel/internal/config"
"github.com/prashar32/riskkernel/internal/memory"
)

// runMemory implements `riskkernel memory <list|show>` — read-only inspection of
// the git-native memory directory (the files you own on disk).
func runMemory(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: riskkernel memory <list [namespace] | show <name> [namespace]>")
}
cfg, err := config.Load()
if err != nil {
return err
}
reader := memory.NewReader(cfg.Memory.Dir)

switch args[0] {
case "list":
ns := ""
if len(args) > 1 {
ns = args[1]
}
entries, err := reader.List(ns)
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Printf("no memory entries under %s\n", reader.Root())
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tFORMAT\tTITLE")
for _, e := range entries {
fmt.Fprintf(tw, "%s\t%s\t%s\n", e.Name, e.Format, e.Title)
}
return tw.Flush()

case "show":
if len(args) < 2 {
return fmt.Errorf("usage: riskkernel memory show <name> [namespace]")
}
name := args[1]
ns := ""
if len(args) > 2 {
ns = args[2]
}
content, _, err := reader.Read(ns, name)
if err != nil {
return err
}
fmt.Print(content)
if len(content) > 0 && content[len(content)-1] != '\n' {
fmt.Println()
}
return nil

default:
return fmt.Errorf("unknown memory subcommand %q (want list|show)", args[0])
}
}
59 changes: 59 additions & 0 deletions examples/memory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Git-native memory

RiskKernel's memory layer is **a directory of files you own** — version them in
git, edit them in your editor. RiskKernel only reads them (it never rewrites your
markdown). Retrieval is deterministic: list, read, and keyword search. There is
**no embedding index / vector DB** in v0.1 (it's a future opt-in).

Point the daemon at a directory:

```bash
RISKKERNEL_MEMORY_DIR=./memory riskkernel serve
```

Organize by **namespace** (a subdirectory). Files are `.md` / `.markdown`,
`.yaml` / `.yml`, or `.txt`. Markdown files may carry simple `key: value`
frontmatter — `title` and `description` are surfaced in listings:

```
memory/
notes/
architecture.md
decisions.md
```

```markdown
---
title: Architecture decisions
description: why we chose SQLite + a pure-Go driver
---

# Architecture

We use a single WAL-mode SQLite file as the default store ...
```

Read it from the daemon (agents) or the CLI (you):

```bash
# files
curl "http://localhost:7070/v1/memory?namespace=notes"
curl "http://localhost:7070/v1/memory/entry?namespace=notes&name=architecture.md"
riskkernel memory list notes
riskkernel memory show architecture.md notes

# episodic facts (small key/value an agent accumulates during runs)
curl -X PUT http://localhost:7070/v1/memory/facts \
-d '{"namespace":"notes","key":"db","value":"sqlite"}'
curl "http://localhost:7070/v1/memory/facts?namespace=notes"
```

From the Python SDK:

```python
import riskkernel as rk
c = rk.RiskKernel()
c.list_memory(namespace="notes")
c.read_memory("architecture.md", namespace="notes")
c.put_fact("notes", "db", "sqlite")
```
9 changes: 9 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/prashar32/riskkernel/internal/gateway"
"github.com/prashar32/riskkernel/internal/governor"
"github.com/prashar32/riskkernel/internal/mcp"
"github.com/prashar32/riskkernel/internal/memory"
"github.com/prashar32/riskkernel/internal/otel"
"github.com/prashar32/riskkernel/internal/pricing"
"github.com/prashar32/riskkernel/internal/provider"
Expand All @@ -36,6 +37,7 @@ type Deps struct {
Tracer *otel.Tracer
Approvals *approval.Gate
MCP *mcp.Gateway // nil when no upstream is configured
Memory *memory.Reader
}

// Close releases dependencies that hold resources (the tracer's buffered spans,
Expand Down Expand Up @@ -96,6 +98,12 @@ func Build(cfg *config.Config) (*Deps, error) {
"allowlist", len(cfg.MCP.Allowlist), "readonly", len(cfg.MCP.ReadOnly))
}

memReader := memory.NewReader(cfg.Memory.Dir)
log.Info("memory layer ready", "dir", memReader.Root())
if cfg.Memory.Embeddings {
log.Warn("RISKKERNEL_MEMORY_EMBEDDINGS is set but embeddings are not implemented in v0.1; using deterministic keyword search")
}

return &Deps{
Config: cfg,
Log: log,
Expand All @@ -107,6 +115,7 @@ func Build(cfg *config.Config) (*Deps, error) {
Tracer: tracer,
Approvals: gate,
MCP: mcpGW,
Memory: memReader,
}, nil
}

Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ type Config struct {
// MCP configures the MCP gateway (tool governance). Disabled unless an upstream
// MCP server URL is set.
MCP MCPConfig

// Memory configures the git-native memory layer.
Memory MemoryConfig
}

// MemoryConfig configures the git-native memory layer: a user-owned directory of
// markdown/YAML the agent reads, plus episodic facts in SQLite.
type MemoryConfig struct {
// Dir is the root memory directory (user-owned, git-native). Read from
// RISKKERNEL_MEMORY_DIR (default "./memory").
Dir string
// Embeddings enables a semantic index. OFF by default and NOT implemented in
// v0.1 — retrieval is deterministic keyword/path search (no vector DB). The
// flag exists so the default posture is explicit. Read from
// RISKKERNEL_MEMORY_EMBEDDINGS (default false).
Embeddings bool
}

// MCPConfig configures the MCP gateway: a JSON-RPC reverse proxy in front of an
Expand Down Expand Up @@ -149,6 +165,10 @@ func Load() (*Config, error) {
ReadOnly: splitList(os.Getenv("RISKKERNEL_MCP_READONLY")),
ApprovalTimeoutSeconds: envIntDefault("RISKKERNEL_MCP_APPROVAL_TIMEOUT", 110),
},
Memory: MemoryConfig{
Dir: getenvDefault("RISKKERNEL_MEMORY_DIR", "./memory"),
Embeddings: envBoolDefault("RISKKERNEL_MEMORY_EMBEDDINGS", false),
},
}
return cfg, nil
}
Expand Down
131 changes: 131 additions & 0 deletions internal/httpapi/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package httpapi

import (
"errors"
"net/http"
"time"

"github.com/prashar32/riskkernel/internal/httpx"
"github.com/prashar32/riskkernel/internal/memory"
"github.com/prashar32/riskkernel/internal/storage"
)

// handleListMemory implements GET /v1/memory?namespace=&q= — list (or keyword
// search) the user-owned markdown/YAML memory entries.
func (s *Server) handleListMemory(w http.ResponseWriter, r *http.Request) {
ns := r.URL.Query().Get("namespace")
q := r.URL.Query().Get("q")
var (
entries []memory.Entry
err error
)
if q != "" {
entries, err = s.memory.Search(ns, q)
} else {
entries, err = s.memory.List(ns)
}
if err != nil {
s.writeMemoryErr(w, err)
return
}
if entries == nil {
entries = []memory.Entry{}
}
httpx.WriteJSON(w, http.StatusOK, entries)
}

// handleReadMemory implements GET /v1/memory/entry?namespace=&name= — read one
// memory file's content + metadata.
func (s *Server) handleReadMemory(w http.ResponseWriter, r *http.Request) {
ns := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
if name == "" {
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "name is required")
return
}
content, e, err := s.memory.Read(ns, name)
if err != nil {
s.writeMemoryErr(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"namespace": e.Namespace, "name": e.Name, "title": e.Title,
"description": e.Description, "format": e.Format,
"size": e.Size, "modTime": e.ModTime, "content": content,
})
}

// handleListFacts implements GET /v1/memory/facts?namespace= — episodic facts.
func (s *Server) handleListFacts(w http.ResponseWriter, r *http.Request) {
store := s.runs.Store()
if store == nil {
httpx.WriteJSON(w, http.StatusOK, []any{})
return
}
facts, err := store.ListFacts(r.Context(), r.URL.Query().Get("namespace"))
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
out := make([]map[string]any, 0, len(facts))
for _, f := range facts {
out = append(out, factView(f))
}
httpx.WriteJSON(w, http.StatusOK, out)
}

type putFactBody struct {
Namespace string `json:"namespace"`
Key string `json:"key"`
Value string `json:"value"`
RunID string `json:"runId"`
}

// handlePutFact implements PUT /v1/memory/facts — write an episodic fact.
func (s *Server) handlePutFact(w http.ResponseWriter, r *http.Request) {
store := s.runs.Store()
if store == nil {
httpx.WriteError(w, http.StatusServiceUnavailable, "no_store", "no durable store configured")
return
}
var body putFactBody
if err := decodeJSON(w, r, &body); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if body.Key == "" {
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "key is required")
return
}
f := storage.Fact{
Namespace: body.Namespace, Key: body.Key, Value: body.Value,
RunID: body.RunID, UpdatedAt: time.Now(),
}
if err := store.PutFact(r.Context(), f); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
httpx.WriteJSON(w, http.StatusCreated, factView(f))
}

func (s *Server) writeMemoryErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, memory.ErrNotFound):
httpx.WriteError(w, http.StatusNotFound, "not_found", "memory entry not found")
case errors.Is(err, memory.ErrUnsafePath):
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "unsafe memory path")
default:
httpx.WriteError(w, http.StatusInternalServerError, "internal_error", err.Error())
}
}

func factView(f storage.Fact) map[string]any {
v := map[string]any{
"namespace": f.Namespace, "key": f.Key, "value": f.Value,
"updatedAt": f.UpdatedAt.Format(time.RFC3339),
}
if f.RunID != "" {
v["runId"] = f.RunID
}
return v
}
Loading
Loading