diff --git a/DOCS.md b/DOCS.md index a0971d245..41b368c6f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -186,7 +186,20 @@ Engram is local-first: local SQLite is authoritative; cloud features are optiona ### Stats / Diagnostics -- `GET /stats` — Memory statistics +- `GET /stats` — Memory statistics. Returns: + ```json + { + "total_sessions": 120, + "total_observations": 100, + "total_created": 107, + "max_observation_id": 112, + "total_prompts": 234, + "projects": ["project-a", "project-b"] + } + ``` + - `total_observations` — active (non-deleted) observations + - `total_created` — all observations ever created, including soft-deleted + - `max_observation_id` — highest ID ever assigned (IDs are autoincrement, never reused) - `GET /doctor` — Read-only operational diagnostics. Query: `?project=X&check=CHECK_CODE` - Returns the same diagnostic report envelope as `engram doctor --json` and MCP `mem_doctor` - `project` and `check` are optional; omitted `project` uses current project detection diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 000000000..e433cad82 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,86 @@ +# Maintainer Handoff + +This document transfers the current local-fork context to another agent or +machine. It describes the state of this fork, not the upstream project's +release state. + +## Authoritative checkout + +Work from this Git repository only. The Codex/Claude plugin caches and Go +module cache are generated installation copies; do not edit them as a source +of truth. + +- Fork remote: `https://github.com/Faturrachman-dev/engram.git` +- Upstream remote: `https://github.com/Gentleman-Programming/engram.git` +- Active branch: `feat/mem-update-project-reassign` +- Latest local-and-pushed commit: `465e81a feat(provenance): record author across agent integrations` + +The previous `engram-src` duplicate worktree was intentionally removed. Do not +recreate or use it for development. + +## What this branch adds + +The branch contains the following local work on top of upstream: + +1. `mem_update` supports a validated `project` argument, allowing one + observation to be reassigned without raw SQLite edits. +2. `/stats` includes `total_created` and `max_observation_id`. +3. The HTTP server serves a dashboard at `/dashboard`, redirects `/` there, + enables browser CORS, and exposes `GET /projects/stats` for project counts. +4. Observations support author provenance end-to-end: + - `mem_save.author` is accepted by MCP. + - When omitted, `ENGRAM_AUTHOR` is used. + - SQLite, imports, API responses, and Obsidian export retain `author`. + - The dashboard shows author information. +5. Pi integration sets authors to `pi/` when the active model is + available, otherwise it uses `ENGRAM_AUTHOR`. +6. `ENGRAM_AGENT_CLI=pi` is supported for semantic conflict scanning. The Pi + runner shells out to the local `pi` CLI and uses that machine's configured + provider/model. + +## Architecture landmarks + +- `cmd/engram/main.go` — CLI command wiring and environment help. +- `internal/mcp/mcp.go` — MCP schemas and tool handlers. +- `internal/store/store.go` — SQLite schema, migrations, and observation + persistence. +- `internal/llm/` — semantic conflict-scan runners; `pi.go` is the local Pi + runner. +- `internal/server/server.go` — HTTP routes, dashboard delivery, and CORS. +- `internal/server/dashboard/index.html` — embedded dashboard UI. +- `plugin/pi/index.ts` — Pi integration and memory tool requests. +- `plugin/codex/` and `plugin/claude-code/` — thin host-agent hooks. Keep + behavior and persistence policy in the Go server where possible. + +## Build and verification + +Run focused verification from the repository root: + +```powershell +go test ./internal/llm ./internal/mcp ./internal/store ./internal/server +``` + +Rebuild the local executable after changing Go code, then restart MCP clients +or their sessions. The installed binary is deliberately separate from the +source checkout; never modify its generated plugin-cache files as a substitute +for rebuilding from this repository. + +## Working conventions + +- Use Conventional Commits and keep the branch name in `type/description` + format. The repository ruleset rejects invalid messages. +- Do not commit generated binaries, databases, credentials, or agent caches. +- Preserve author provenance as an agent/model label, never as a credential or + captured request payload. +- Before changing a plugin hook, read `skills/plugin-thin/SKILL.md`; before + changing persistence or project resolution, read + `skills/business-rules/SKILL.md`. + +## Follow-up work worth checking + +- `DOCS.md` still describes `ENGRAM_AGENT_CLI` as accepting only `claude` and + `opencode`; update it when preparing this branch for broader review. +- Add focused MCP/store tests for author migration and persistence if this + branch will be proposed upstream. +- Rebuild and smoke-test the installed executable on each machine after pulling + this branch; the executable itself is intentionally not committed. diff --git a/README.md b/README.md index aac171c20..1109ba960 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ArchitecturePluginsTeam Usage • + Maintainer HandoffContributingFull Docs

@@ -290,7 +291,7 @@ curl -s "http://127.0.0.1:7437/conflicts?project=beta-test" | jq **4️⃣ Phase 4 — Semantic LLM-judge (the killer feature) 🎯** ```bash -export ENGRAM_AGENT_CLI=claude # or opencode +export ENGRAM_AGENT_CLI=claude # or opencode, or pi (routes via your provider config — cheap) ./engram-beta conflicts scan --project beta-test --semantic --apply \ --max-semantic 5 --concurrency 3 --yes @@ -390,6 +391,7 @@ Full environment variable reference → [DOCS.md#environment-variables](DOCS.md# | [Codebase Guide](docs/CODEBASE-GUIDE.md) | Guide to the repository structure, flows, and implementation landmarks | | [Architecture](docs/ARCHITECTURE.md) | How it works + MCP tools + project structure | | [Plugins](docs/PLUGINS.md) | OpenCode & Claude Code plugin details | +| [Maintainer Handoff](HANDOFF.md) | Local fork state and cross-machine continuation notes | | [Comparison](docs/COMPARISON.md) | Why Engram vs claude-mem | | [Intended Usage](docs/intended-usage.md) | Mental model — how Engram is meant to be used | | [Obsidian Brain](docs/beta/obsidian-brain.md) | Export memories as Obsidian knowledge graph (beta) | diff --git a/cmd/engram/llm.go b/cmd/engram/llm.go index 473b0c398..aa210010a 100644 --- a/cmd/engram/llm.go +++ b/cmd/engram/llm.go @@ -69,7 +69,7 @@ func llmBuildPrompt(a, b store.ObservationSnippet) string { func resolveAgentRunner() (store.SemanticRunner, error) { name := os.Getenv("ENGRAM_AGENT_CLI") if name == "" { - return nil, errors.New("ENGRAM_AGENT_CLI is not set; required for --semantic scan (set to 'claude' or 'opencode')") + return nil, errors.New("ENGRAM_AGENT_CLI is not set; required for --semantic scan (set to 'claude', 'opencode', or 'pi')") } return agentRunnerFactory(name) } diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 730d783f9..4100bb928 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -2721,7 +2721,7 @@ Environment: ENGRAM_TIMEZONE Timezone for timestamp display in TUI and cloud dashboard. Accepts any IANA zone name (e.g. America/New_York, Europe/Berlin). Falls back to system local time when unset or invalid. - ENGRAM_AGENT_CLI LLM runner for conflicts scan --semantic (claude or opencode) + ENGRAM_AGENT_CLI LLM runner for conflicts scan --semantic (claude, opencode, or pi) ENGRAM_CLOUD_AUTOSYNC Set to 1 to enable background autosync; also requires ENGRAM_CLOUD_TOKEN and ENGRAM_CLOUD_SERVER diff --git a/internal/llm/factory.go b/internal/llm/factory.go index 5ed7d0434..e74dcb2b9 100644 --- a/internal/llm/factory.go +++ b/internal/llm/factory.go @@ -17,6 +17,8 @@ var ErrInvalidRunnerName = errors.New("invalid runner name") // Supported values: // - "claude" → *ClaudeRunner (shells out to the claude CLI) // - "opencode" → *OpenCodeRunner (shells out to the opencode CLI) +// - "pi" → *PiRunner (shells out to the pi CLI; routes via the user's +// own provider config, e.g. a cheap 9router model) // // For any other value, including the empty string, a descriptive error is // returned that names the ENGRAM_AGENT_CLI environment variable and the @@ -33,15 +35,18 @@ func NewRunner(name string) (AgentRunner, error) { case "opencode": return NewOpenCodeRunner(), nil + case "pi": + return NewPiRunner(), nil + case "": return nil, fmt.Errorf( - "%w: ENGRAM_AGENT_CLI is not set; supported values are: claude, opencode", + "%w: ENGRAM_AGENT_CLI is not set; supported values are: claude, opencode, pi", ErrInvalidRunnerName, ) default: return nil, fmt.Errorf( - "%w: %q is not a recognized runner; set ENGRAM_AGENT_CLI to one of: claude, opencode", + "%w: %q is not a recognized runner; set ENGRAM_AGENT_CLI to one of: claude, opencode, pi", ErrInvalidRunnerName, name, ) diff --git a/internal/llm/pi.go b/internal/llm/pi.go new file mode 100644 index 000000000..107a10c00 --- /dev/null +++ b/internal/llm/pi.go @@ -0,0 +1,140 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "strings" +) + +// ─── PiRunner ───────────────────────────────────────────────────────────────── + +// PiRunner implements AgentRunner by shelling out to the `pi` CLI. +// It invokes: pi -p --mode json --no-context-files (with the prompt on stdin) +// and parses Pi's NDJSON event stream, accumulating assistant text deltas into +// the final message which is then parsed as a Verdict JSON object. +// +// Pi routes through the user's own provider configuration (e.g. a cheap +// 9router model), which makes it the low-cost background consolidation runner: +// set ENGRAM_AGENT_CLI=pi to drive `conflicts scan --semantic` with it. +type PiRunner struct { + // runCLI is the shell-out function. Defaults to defaultRunCLI. + // Tests inject a fake implementation to avoid spawning real processes. + runCLI func(ctx context.Context, name string, args []string, stdin string) ([]byte, error) +} + +// NewPiRunner constructs a PiRunner with the real exec.CommandContext +// implementation. Tests should inject a fake via the struct field directly. +func NewPiRunner() *PiRunner { + return &PiRunner{runCLI: defaultRunCLI} +} + +// Compare sends prompt to the Pi CLI and returns a structured Verdict. +// Invokes: pi -p --mode json --no-context-files +// +// Pi's output is NDJSON (newline-delimited JSON). Assistant text arrives as a +// stream of "message_update" events carrying "text_delta" chunks; the runner +// concatenates those chunks and parses the assembled message as a Verdict. +func (r *PiRunner) Compare(ctx context.Context, prompt string) (Verdict, error) { + args := []string{"-p", "--mode", "json", "--no-context-files"} + raw, err := r.runCLI(ctx, "pi", args, prompt) + if err != nil { + // Propagate sentinel errors directly (e.g. ErrCLINotInstalled). + return Verdict{}, err + } + + return parsePiNDJSON(raw) +} + +// ─── Compile-time interface satisfaction ────────────────────────────────────── + +var _ AgentRunner = (*PiRunner)(nil) + +// ─── NDJSON parsing ─────────────────────────────────────────────────────────── + +// piEvent is the generic envelope for each NDJSON line Pi emits in --mode json. +type piEvent struct { + Type string `json:"type"` + AssistantMessageEvent *piAssistantMsg `json:"assistantMessageEvent,omitempty"` +} + +// piAssistantMsg is the payload of a "message_update" event. +type piAssistantMsg struct { + Type string `json:"type"` // text_delta | thinking_delta | ... + Delta string `json:"delta"` + Model string `json:"model,omitempty"` +} + +// parsePiNDJSON scans Pi's NDJSON output, concatenates assistant text_delta +// chunks into the final message, and parses it as a Verdict JSON object. +// Malformed lines (Pi prints a non-JSON banner before the stream) and non-text +// events are skipped; thinking_delta chunks are ignored (reasoning stream, not +// the answer). +func parsePiNDJSON(raw []byte) (Verdict, error) { + scanner := bufio.NewScanner(bytes.NewReader(raw)) + // Pi echoes large payloads on a single line; raise the token cap well above + // bufio's 64KB default so long lines don't abort the scan. + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + var ( + text strings.Builder + model string + ) + + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + var ev piEvent + if err := json.Unmarshal(line, &ev); err != nil { + // Malformed line: skip and continue. + continue + } + + if ev.Type == "message_update" && ev.AssistantMessageEvent != nil { + ame := ev.AssistantMessageEvent + if ame.Type == "text_delta" && ame.Delta != "" { + text.WriteString(ame.Delta) + } + if ame.Model != "" { + model = ame.Model + } + } + } + + final := strings.TrimSpace(text.String()) + if final == "" { + return Verdict{}, fmt.Errorf("pi: no assistant text found in NDJSON stream") + } + + // Strip optional markdown code fences before parsing the inner Verdict JSON. + if m := fenceRE.FindStringSubmatch(final); len(m) == 2 { + final = strings.TrimSpace(m[1]) + } + + var iv innerVerdict + if err := json.Unmarshal([]byte(final), &iv); err != nil { + return Verdict{}, fmt.Errorf("%w: inner verdict from pi text: %v", ErrInvalidJSON, err) + } + + // Validate the relation verb. + if !validRelations[iv.Relation] { + return Verdict{}, fmt.Errorf("%w: %q", ErrUnknownRelation, iv.Relation) + } + + // Prefer a model reported by the stream, else the inner JSON field. + if model == "" { + model = iv.Model + } + + return Verdict{ + Relation: iv.Relation, + Confidence: iv.Confidence, + Reasoning: iv.Reasoning, + Model: model, + }, nil +} diff --git a/internal/llm/pi_test.go b/internal/llm/pi_test.go new file mode 100644 index 000000000..84a21e10d --- /dev/null +++ b/internal/llm/pi_test.go @@ -0,0 +1,136 @@ +package llm + +// Note: this test file lives in package llm (not llm_test) so it can inject +// the runCLI function directly on the struct for unit testing. + +import ( + "context" + "errors" + "testing" +) + +// ─── PiRunner tests ──────────────────────────────────────────────────────────── + +// TestPiRunner_CompileTimeCheck verifies PiRunner satisfies AgentRunner. +var _ AgentRunner = (*PiRunner)(nil) + +// TestPiRunner_GoldenNDJSON verifies the runner concatenates text_delta chunks +// into a message that parses as the expected Verdict JSON. +func TestPiRunner_GoldenNDJSON(t *testing.T) { + // Pi streams the verdict JSON across several text_delta chunks. + ndjson := `Warning: no project session found; starting fresh +{"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","delta":"comparing..."}} +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"{\"Relation\":\"conflicts_with\","}} +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"\"Confidence\":0.9,\"Reasoning\":\"A and B contradict\",\"Model\":\"deepseek-v4-pro\"}"}} +` + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + v, err := r.Compare(context.Background(), "compare") + if err != nil { + t.Fatalf("Compare: unexpected error: %v", err) + } + if v.Relation != "conflicts_with" { + t.Errorf("Relation = %q; want %q", v.Relation, "conflicts_with") + } + if v.Confidence != 0.9 { + t.Errorf("Confidence = %v; want 0.9", v.Confidence) + } + if v.Reasoning != "A and B contradict" { + t.Errorf("Reasoning = %q; want %q", v.Reasoning, "A and B contradict") + } + if v.Model != "deepseek-v4-pro" { + t.Errorf("Model = %q; want %q", v.Model, "deepseek-v4-pro") + } +} + +// TestPiRunner_FencedJSON verifies markdown code fences around the assembled +// message are stripped before parsing. +func TestPiRunner_FencedJSON(t *testing.T) { + ndjson := "{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_delta\",\"delta\":\"```json\\n{\\\"Relation\\\":\\\"scoped\\\",\\\"Confidence\\\":0.75,\\\"Reasoning\\\":\\\"B narrows A\\\",\\\"Model\\\":\\\"m\\\"}\\n```\"}}\n" + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + v, err := r.Compare(context.Background(), "compare") + if err != nil { + t.Fatalf("Compare with fenced JSON: unexpected error: %v", err) + } + if v.Relation != "scoped" { + t.Errorf("Relation = %q; want %q", v.Relation, "scoped") + } +} + +// TestPiRunner_NoText verifies that a stream with no assistant text returns a +// descriptive error. +func TestPiRunner_NoText(t *testing.T) { + ndjson := `{"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","delta":"only thinking"}} +` + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + _, err := r.Compare(context.Background(), "compare") + if err == nil { + t.Fatal("expected error for missing assistant text; got nil") + } +} + +// TestPiRunner_MalformedLine verifies malformed NDJSON lines are skipped and +// processing continues to a valid verdict. +func TestPiRunner_MalformedLine(t *testing.T) { + ndjson := `not json at all +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"{\"Relation\":\"related\",\"Confidence\":0.6,\"Reasoning\":\"same topic\",\"Model\":\"m\"}"}} +` + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + v, err := r.Compare(context.Background(), "compare") + if err != nil { + t.Fatalf("Compare with malformed line: unexpected error: %v", err) + } + if v.Relation != "related" { + t.Errorf("Relation = %q; want %q", v.Relation, "related") + } +} + +// TestPiRunner_InvalidInnerJSON verifies ErrInvalidJSON is returned when the +// assembled text is not valid JSON. +func TestPiRunner_InvalidInnerJSON(t *testing.T) { + ndjson := `{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"this is not json"}} +` + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrInvalidJSON) { + t.Errorf("expected ErrInvalidJSON; got %v", err) + } +} + +// TestPiRunner_UnknownRelation verifies ErrUnknownRelation is returned when the +// verdict contains an unrecognized relation verb. +func TestPiRunner_UnknownRelation(t *testing.T) { + ndjson := `{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"{\"Relation\":\"maybe\",\"Confidence\":0.5,\"Reasoning\":\"dunno\",\"Model\":\"m\"}"}} +` + + r := &PiRunner{runCLI: fakeCLI([]byte(ndjson), nil)} + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrUnknownRelation) { + t.Errorf("expected ErrUnknownRelation; got %v", err) + } +} + +// TestPiRunner_CLIError verifies that runCLI errors are propagated. +func TestPiRunner_CLIError(t *testing.T) { + cliErr := errors.New("pi failed") + r := &PiRunner{runCLI: fakeCLI(nil, cliErr)} + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, cliErr) { + t.Errorf("expected cliErr; got %v", err) + } +} + +// TestNewRunner_Pi verifies the factory routes "pi" to a *PiRunner. +func TestNewRunner_Pi(t *testing.T) { + r, err := NewRunner("pi") + if err != nil { + t.Fatalf("NewRunner(pi): unexpected error: %v", err) + } + if _, ok := r.(*PiRunner); !ok { + t.Errorf("NewRunner(pi) = %T; want *PiRunner", r) + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e1fb4d161..6b6162bf8 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -352,6 +352,9 @@ Examples: mcp.WithString("topic_key", mcp.Description("Optional topic identifier for upserts (e.g. architecture/auth-model). Reuses and updates the latest observation in same project+scope."), ), + mcp.WithString("author", + mcp.Description("Who authored this memory, as agent/model (e.g. 'claude-code/opus-4.8', 'pi/deepseek-v4-pro'). Falls back to the ENGRAM_AUTHOR env var when unset."), + ), mcp.WithString("project", mcp.Description("Optional explicit project for this memory. Accepted only when backed by known context (existing project, matching session, repo config, or ambiguous-project recovery); invalid or unbacked names fail loudly."), ), @@ -399,6 +402,9 @@ Examples: mcp.WithString("topic_key", mcp.Description("New topic key (normalized internally)"), ), + mcp.WithString("project", + mcp.Description("New project — reassign this observation to a different project"), + ), ), queuedWriteHandler(writeQueue, handleUpdate(s)), ) @@ -1199,6 +1205,10 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server sessionID, _ := req.GetArguments()["session_id"].(string) scope, _ := req.GetArguments()["scope"].(string) topicKey, _ := req.GetArguments()["topic_key"].(string) + author, _ := req.GetArguments()["author"].(string) + if strings.TrimSpace(author) == "" { + author = strings.TrimSpace(os.Getenv("ENGRAM_AUTHOR")) + } projectChoice, _ := req.GetArguments()["project"].(string) _, explicitProjectProvided := req.GetArguments()["project"] projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string) @@ -1269,6 +1279,7 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server Project: project, Scope: scope, TopicKey: topicKey, + Author: author, }) if err != nil { return mcp.NewToolResultError("Failed to save: " + err.Error()), nil @@ -1410,6 +1421,9 @@ func handleUpdate(s *store.Store) server.ToolHandlerFunc { if v, ok := req.GetArguments()["topic_key"].(string); ok { update.TopicKey = &v } + if v, ok := req.GetArguments()["project"].(string); ok { + update.Project = &v + } if update.Title == nil && update.Content == nil && update.Type == nil && update.Project == nil && update.Scope == nil && update.TopicKey == nil { return mcp.NewToolResultError("provide at least one field to update"), nil @@ -1917,6 +1931,7 @@ func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivi Title: fmt.Sprintf("Session summary: %s", project), Content: content, Project: project, + Author: strings.TrimSpace(os.Getenv("ENGRAM_AUTHOR")), }) if err != nil { return mcp.NewToolResultError("Failed to save session summary: " + err.Error()), nil diff --git a/internal/obsidian/markdown.go b/internal/obsidian/markdown.go index 8c0cc40c5..3be99cbd0 100644 --- a/internal/obsidian/markdown.go +++ b/internal/obsidian/markdown.go @@ -35,6 +35,9 @@ func ObservationToMarkdown(obs store.Observation) string { } fmt.Fprintf(&sb, "session_id: %s\n", obs.SessionID) fmt.Fprintf(&sb, "created_at: %q\n", obs.CreatedAt) + if obs.Author != nil && *obs.Author != "" { + fmt.Fprintf(&sb, "author: %q\n", *obs.Author) + } fmt.Fprintf(&sb, "updated_at: %q\n", obs.UpdatedAt) fmt.Fprintf(&sb, "revision_count: %d\n", obs.RevisionCount) fmt.Fprintf(&sb, "tags:\n - %s\n", project) diff --git a/internal/server/dashboard/index.html b/internal/server/dashboard/index.html new file mode 100644 index 000000000..f41248da3 --- /dev/null +++ b/internal/server/dashboard/index.html @@ -0,0 +1,397 @@ + + + + + +Engram Dashboard + + + + +
+ +
+
Select a project or search to begin
+
+
+
+
+
+

+
+
+
+ + + +
+
+
+
+
+
+
+ +
+ + + diff --git a/internal/server/server.go b/internal/server/server.go index c30f66a11..203ee2162 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "crypto/hmac" "crypto/subtle" "database/sql" + "embed" "encoding/json" "errors" "fmt" @@ -25,6 +26,9 @@ import ( "github.com/Gentleman-Programming/engram/internal/store" ) +//go:embed dashboard/* +var dashboardFS embed.FS + var loadServerStats = func(s *store.Store) (*store.Stats, error) { return s.Stats() } @@ -167,12 +171,27 @@ func (s *Server) Start() error { serveFn = http.Serve } + handler := corsMiddleware(s.mux) + ln, err := listenFn("tcp", addr) if err != nil { return fmt.Errorf("engram server: listen %s: %w", addr, err) } log.Printf("[engram] HTTP server listening on %s", addr) - return serveFn(ln, s.mux) + return serveFn(ln, handler) +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) } func (s *Server) Handler() http.Handler { @@ -180,8 +199,17 @@ func (s *Server) Handler() http.Handler { } func (s *Server) routes() { + s.mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) + }) + s.mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) s.mux.HandleFunc("GET /health", s.handleHealth) + // Dashboard + s.mux.HandleFunc("GET /dashboard", s.handleDashboard) + // Sessions s.mux.HandleFunc("POST /sessions", s.handleCreateSession) s.mux.HandleFunc("POST /sessions/{id}/end", s.handleEndSession) @@ -223,6 +251,7 @@ func (s *Server) routes() { // Stats / diagnostics s.mux.HandleFunc("GET /stats", s.handleStats) + s.mux.HandleFunc("GET /projects/stats", s.handleProjectStats) s.mux.HandleFunc("GET /doctor", s.handleDoctor) // Project detection / migration @@ -253,6 +282,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + html, _ := dashboardFS.ReadFile("dashboard/index.html") + w.Write(html) +} + func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { var body struct { ID string `json:"id"` @@ -789,7 +824,15 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusInternalServerError, err.Error()) return } + jsonResponse(w, http.StatusOK, stats) +} +func (s *Server) handleProjectStats(w http.ResponseWriter, r *http.Request) { + stats, err := s.store.ListProjectsWithStats() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } jsonResponse(w, http.StatusOK, stats) } diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b98..63925316c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -93,6 +93,7 @@ type Observation struct { Title string `json:"title"` Content string `json:"content"` ToolName *string `json:"tool_name,omitempty"` + Author *string `json:"author,omitempty"` Project *string `json:"project,omitempty"` Scope string `json:"scope"` TopicKey *string `json:"topic_key,omitempty"` @@ -143,6 +144,8 @@ type SessionSummary struct { type Stats struct { TotalSessions int `json:"total_sessions"` TotalObservations int `json:"total_observations"` + TotalCreated int `json:"total_created"` + MaxObservationID int64 `json:"max_observation_id"` TotalPrompts int `json:"total_prompts"` Projects []string `json:"projects"` } @@ -188,6 +191,7 @@ type AddObservationParams struct { Title string `json:"title"` Content string `json:"content"` ToolName string `json:"tool_name,omitempty"` + Author string `json:"author,omitempty"` Project string `json:"project,omitempty"` Scope string `json:"scope,omitempty"` TopicKey string `json:"topic_key,omitempty"` @@ -254,7 +258,7 @@ var decayReviewAfterMonths = map[string]int{ } const observationSelectColumns = `id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, - scope, topic_key, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at` + scope, topic_key, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at, author` type SyncState struct { TargetKey string `json:"target_key"` @@ -709,6 +713,7 @@ func (s *Store) migrate() error { title TEXT NOT NULL, content TEXT NOT NULL, tool_name TEXT, + author TEXT, project TEXT, scope TEXT NOT NULL DEFAULT 'project', topic_key TEXT, @@ -822,6 +827,7 @@ func (s *Store) migrate() error { definition string }{ {name: "sync_id", definition: "TEXT"}, + {name: "author", definition: "TEXT"}, {name: "scope", definition: "TEXT NOT NULL DEFAULT 'project'"}, {name: "topic_key", definition: "TEXT"}, {name: "normalized_hash", definition: "TEXT"}, @@ -2352,10 +2358,10 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { syncID := newSyncID("obs") res, err := s.execHook(tx, - `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, datetime('now'), datetime('now'))`, + `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, author, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, datetime('now'), datetime('now'))`, syncID, p.SessionID, p.Type, title, content, - nullableString(p.ToolName), nullableString(p.Project), scope, nullableString(topicKey), normHash, + nullableString(p.ToolName), nullableString(p.Author), nullableString(p.Project), scope, nullableString(topicKey), normHash, ) if err != nil { return err @@ -3242,6 +3248,8 @@ func (s *Store) Stats() (*Stats, error) { s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&stats.TotalSessions) s.db.QueryRow("SELECT COUNT(*) FROM observations WHERE deleted_at IS NULL").Scan(&stats.TotalObservations) + s.db.QueryRow("SELECT COUNT(*) FROM observations").Scan(&stats.TotalCreated) + s.db.QueryRow("SELECT COALESCE(MAX(id), 0) FROM observations").Scan(&stats.MaxObservationID) s.db.QueryRow("SELECT COUNT(*) FROM user_prompts").Scan(&stats.TotalPrompts) rows, err := s.queryItHook(s.db, "SELECT project FROM observations WHERE project IS NOT NULL AND deleted_at IS NULL GROUP BY project ORDER BY MAX(created_at) DESC") @@ -3569,8 +3577,8 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { for _, obs := range data.Observations { syncID := normalizeExistingSyncID(obs.SyncID, "obs") res, err := s.execHook(tx, - `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, review_after, created_at, updated_at, deleted_at) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, author, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, review_after, created_at, updated_at, deleted_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM observations WHERE sync_id = ?)`, syncID, obs.SessionID, @@ -3578,6 +3586,7 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { obs.Title, obs.Content, obs.ToolName, + obs.Author, obs.Project, normalizeScope(obs.Scope), nullableString(normalizeTopicKey(derefString(obs.TopicKey))), @@ -6057,7 +6066,7 @@ func scanObservationRow(scanner observationScanner, o *Observation) error { return scanner.Scan( &o.ID, &o.SyncID, &o.SessionID, &o.Type, &o.Title, &o.Content, &o.ToolName, &o.Project, &o.Scope, &o.TopicKey, &o.RevisionCount, &o.DuplicateCount, &o.LastSeenAt, &o.ReviewAfter, - &o.Pinned, &o.CreatedAt, &o.UpdatedAt, &o.DeletedAt, + &o.Pinned, &o.CreatedAt, &o.UpdatedAt, &o.DeletedAt, &o.Author, ) } @@ -6250,6 +6259,7 @@ func (s *Store) migrateLegacyObservationsTable() error { title TEXT NOT NULL, content TEXT NOT NULL, tool_name TEXT, + author TEXT, project TEXT, scope TEXT NOT NULL DEFAULT 'project', topic_key TEXT, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5ed55ca63..47a4319dd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8830,3 +8830,43 @@ func TestSanitizeFTS(t *testing.T) { }) } } + +func TestUpdateObservationReassignsProject(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s1", "alpha", "/tmp/alpha"); err != nil { + t.Fatalf("create session: %v", err) + } + + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "config", + Title: "movable", + Content: "belongs elsewhere", + Project: "alpha", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + newProject := "beta" + updated, err := s.UpdateObservation(id, UpdateObservationParams{ + Project: &newProject, + }) + if err != nil { + t.Fatalf("update observation: %v", err) + } + if derefString(updated.Project) != "beta" { + t.Fatalf("project reassignment did not apply; got project=%q, want %q", derefString(updated.Project), "beta") + } + + // Confirm it persisted on re-read. + got, err := s.GetObservation(id) + if err != nil { + t.Fatalf("get observation: %v", err) + } + if derefString(got.Project) != "beta" { + t.Fatalf("reassignment not persisted; got project=%q, want %q", derefString(got.Project), "beta") + } +} diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index 33a5523d2..524d35406 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -654,6 +654,12 @@ async function callMemoryTool(toolName: string, params: Record, const activeProject = requestedProject || project; const activeSessionId = String(params.session_id || (requestedProject ? `manual-save-${requestedProject}` : sessionId) || `manual-save-${project}`); + // Provenance: who authored this memory, as "pi/". Falls back to + // ENGRAM_AUTHOR when the active model is not exposed on the context. + const activeModel = (ctx as { model?: { id?: string; name?: string } }).model; + const modelId = activeModel?.id || activeModel?.name; + const author = modelId ? `pi/${modelId}` : (process.env.ENGRAM_AUTHOR?.trim() || undefined); + switch (toolName) { case "mem_search": return engramFetch(`/search${queryString({ @@ -687,6 +693,7 @@ async function callMemoryTool(toolName: string, params: Record, project: activeProject, scope: params.scope || "project", topic_key: params.topic_key, + author, }, }); case "mem_update": @@ -723,6 +730,7 @@ async function callMemoryTool(toolName: string, params: Record, content: params.content, project: activeProject, scope: "project", + author, }, }); case "mem_session_start":