From 18dd4100af0a7e3703043b9391dd247f9c735251 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Tue, 28 Jul 2026 23:57:19 +0700 Subject: [PATCH 1/6] feat(mcp): support project reassignment in mem_update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mem_update accepted title/content/type/scope/topic_key but not project, so an observation saved under the wrong project could never be moved — the only recourse was raw SQL or an export/edit/reimport round-trip. The store layer already supported this: UpdateObservationParams has a Project field, UpdateObservation writes it (with NormalizeProject), and handleUpdate's guard already referenced update.Project. Only the tool schema and the argument parse were missing. This wires both and adds a store-level test. Co-Authored-By: Claude Opus 4.8 --- internal/mcp/mcp.go | 6 ++++++ internal/store/store_test.go | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e1fb4d161..2f63a0d47 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -399,6 +399,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)), ) @@ -1410,6 +1413,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 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") + } +} From b7bc3a77ad13db23406b6302a313248f0fded921 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Sun, 2 Aug 2026 02:30:09 +0700 Subject: [PATCH 2/6] feat: add total_created and max_observation_id to /stats endpoint Co-authored-by: CommandCodeBot --- internal/store/store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b98..de6aa1051 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -143,6 +143,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"` } @@ -3242,6 +3244,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") From 408929dd0c3b86f530035894271bd9890e950166 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Sun, 2 Aug 2026 02:31:20 +0700 Subject: [PATCH 3/6] docs: document /stats response fields (total_created, max_observation_id) Co-authored-by: CommandCodeBot --- DOCS.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 From 85ff62be5087353be702ead9c6b22529070e9963 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Sun, 2 Aug 2026 04:16:08 +0700 Subject: [PATCH 4/6] feat: add web dashboard at /dashboard with CORS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CORS middleware on all endpoints (browser fetch works) - /dashboard route serves embedded HTML at build time - Full dashboard: project filter sidebar, search, detail panel, inline edit modal, delete with confirmation, stats display - Dashboard calls Engram REST API directly — zero backend changes needed Co-authored-by: CommandCodeBot --- internal/server/dashboard/index.html | 278 +++++++++++++++++++++++++++ internal/server/server.go | 30 ++- 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 internal/server/dashboard/index.html diff --git a/internal/server/dashboard/index.html b/internal/server/dashboard/index.html new file mode 100644 index 000000000..9a55a9f48 --- /dev/null +++ b/internal/server/dashboard/index.html @@ -0,0 +1,278 @@ + + + + + +Engram Dashboard + + + + +
+ +
+
Select a project or search to begin
+
+
+
+

+
+ + + +
+
+
+
+
+
+ +
+ + + diff --git a/internal/server/server.go b/internal/server/server.go index c30f66a11..ff5c0f010 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 { @@ -182,6 +201,9 @@ func (s *Server) Handler() http.Handler { func (s *Server) routes() { 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) @@ -253,6 +275,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"` From 465e81ad9faa411c0641b129f736b1c82ced6228 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Sat, 5 Sep 2026 03:44:18 +0700 Subject: [PATCH 5/6] feat(provenance): record author across agent integrations --- README.md | 2 +- cmd/engram/llm.go | 2 +- cmd/engram/main.go | 2 +- internal/llm/factory.go | 9 +- internal/llm/pi.go | 140 +++++++++++++++++++++++ internal/llm/pi_test.go | 136 +++++++++++++++++++++++ internal/mcp/mcp.go | 9 ++ internal/obsidian/markdown.go | 3 + internal/server/dashboard/index.html | 159 +++++++++++++++++++++++---- internal/server/server.go | 15 +++ internal/store/store.go | 20 ++-- plugin/pi/index.ts | 8 ++ 12 files changed, 473 insertions(+), 32 deletions(-) create mode 100644 internal/llm/pi.go create mode 100644 internal/llm/pi_test.go diff --git a/README.md b/README.md index aac171c20..f1ea80aeb 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,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 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 2f63a0d47..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."), ), @@ -1202,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) @@ -1272,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 @@ -1923,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 index 9a55a9f48..f41248da3 100644 --- a/internal/server/dashboard/index.html +++ b/internal/server/dashboard/index.html @@ -47,10 +47,46 @@ .obs-card .title { font-size: 15px; font-weight: 600; margin-bottom: 4px; } .obs-card .preview { font-size: 13px; color: var(--fg2); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } .obs-card .project-tag { color: var(--purple); font-size: 11px; } -#detail-panel { border-top: 1px solid var(--border); background: var(--bg2); max-height: 50vh; overflow-y: auto; display: none; } -#detail-panel.open { display: block; } -#detail-panel .detail-header { padding: 14px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: flex-start; } -#detail-panel .detail-body { padding: 16px 24px; white-space: pre-wrap; font-size: 13px; line-height: 1.6; } +.obs-card .author-tag { color: var(--green, #6ea77f); font-size: 11px; opacity: .85; } +#detail-panel { position: fixed; inset: 0; z-index: 90; display: none; align-items: center; justify-content: center; padding: 40px; } +#detail-panel.open { display: flex; } +#detail-panel::before { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.55); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } +#detail-panel .detail-card { position: relative; width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; background: rgba(22,27,34,.72); backdrop-filter: blur(18px) saturate(1.2); -webkit-backdrop-filter: blur(18px) saturate(1.2); border: 1px solid rgba(120,150,190,.35); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.04) inset; overflow: hidden; } +#detail-panel .detail-header { padding: 18px 22px; border-bottom: 1px solid rgba(120,150,190,.22); display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; background: rgba(255,255,255,.03); } +#detail-panel .detail-body { padding: 20px 22px; font-size: 13px; line-height: 1.65; overflow-y: auto; overflow-x: hidden; } +#detail-panel .detail-body p { margin: 0 0 10px; } +#detail-panel .detail-body p:last-child { margin-bottom: 0; } +#detail-panel .detail-body h1,#detail-panel .detail-body h2,#detail-panel .detail-body h3,#detail-panel .detail-body h4 { margin: 16px 0 8px; font-weight: 700; line-height: 1.3; color: var(--fg); } +#detail-panel .detail-body h1 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; } +#detail-panel .detail-body h2 { font-size: 15px; } +#detail-panel .detail-body h3 { font-size: 13px; } +#detail-panel .detail-body strong { font-weight: 700; color: var(--fg); } +#detail-panel .detail-body em { font-style: italic; color: var(--fg); } +#detail-panel .detail-body code { background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; border: 1px solid rgba(255,255,255,.08); word-break: break-word; } +#detail-panel .detail-body pre { background: #0d1117; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); } +#detail-panel .detail-body pre code { background: transparent; border: none; padding: 0; font-size: 12px; display: block; white-space: pre; word-break: normal; } +#detail-panel .detail-body ul, #detail-panel .detail-body ol { margin: 8px 0 10px 20px; } +#detail-panel .detail-body li { margin: 4px 0; } +#detail-panel .detail-body blockquote { border-left: 3px solid var(--accent); margin: 10px 0; padding: 8px 12px; border-radius: 0 6px 6px 0; background: rgba(88,166,255,.07); color: var(--fg2); font-style: italic; } +#detail-panel .detail-body a { color: var(--accent); text-decoration: none; word-break: break-all; } +#detail-panel .detail-body a:hover { text-decoration: underline; } +#detail-panel .detail-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; } +#detail-panel .detail-body table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; } +#detail-panel .detail-body th, #detail-panel .detail-body td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; } +#detail-panel .detail-body th { background: var(--bg3); font-weight: 600; } +#detail-panel .detail-body .wiki-link { color: var(--purple); background: rgba(188,140,255,.12); padding: 1px 4px; border-radius: 4px; font-size: 12px; } +.detail-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.detail-title-row h2 { font-size: 16px; font-weight: 700; line-height: 1.3; } +.detail-meta-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: center; } +.meta-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; font-size: 11px; line-height: 1; border: 1px solid rgba(255,255,255,.10); background: rgba(255,255,255,.06); max-width: 100%; } +.meta-chip .k { color: var(--fg2); font-size: 10px; text-transform: uppercase; letter-spacing: .35px; font-weight: 700; white-space: nowrap; } +.meta-chip .v { color: var(--fg); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } +.meta-chip.mono .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.meta-chip.project { border-color: rgba(188,140,255,.30); background: rgba(188,140,255,.14); } +.meta-chip.project .v { color: #d6bcff; } +.meta-chip.author { border-color: rgba(63,185,80,.30); background: rgba(63,185,80,.14); } +.meta-chip.author .v { color: #7ee787; } +.meta-chip.scope { border-color: rgba(88,166,255,.30); background: rgba(88,166,255,.13); } .detail-actions { display: flex; gap: 8px; } .detail-actions button { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border); font-size: 12px; cursor: pointer; } .btn-edit { background: var(--accent); color: #000; border-color: var(--accent); } @@ -90,8 +126,12 @@

All Memories

Select a project or search to begin
+
-

+
+

+
+
@@ -99,6 +139,7 @@

All Memories

+
@@ -155,22 +196,26 @@

All Memories

const list = document.getElementById("project-list"); list.innerHTML = `
  • All Projects ${s.total_observations}
  • `; s.projects.forEach(p => { - const c = ""; // counts come from group query below - list.innerHTML += `
  • ${p}
  • `; + list.innerHTML += `
  • ${p}
  • `; }); loadProjectCounts(s.projects); } catch(e) { document.getElementById("stats-box").textContent = "Engram offline"; } } async function loadProjectCounts(projects) { - for (const p of projects) { - try { - const data = await api(`/observations?project=${encodeURIComponent(p)}&limit=1`); - const count = Array.isArray(data) ? data.length : 0; - const li = document.querySelector(`#project-list li[onclick="setProject('${p}')"]`); - if (li && count > 0) li.querySelector(".count") || (li.innerHTML += `${count}`); - } catch(e) {} - } + const list = document.getElementById("project-list"); + try { + const stats = await api("/projects/stats"); + const counts = {}; + (stats || []).forEach(p => { counts[p.name] = p.observation_count || 0; }); + projects.forEach(p => { + const li = list.querySelector(`li[data-project="${CSS.escape(p)}"]`); + if (li) { + const count = counts[p] || 0; + li.innerHTML += `${count}`; + } + }); + } catch(e) {} } async function loadObservations(project) { @@ -193,7 +238,7 @@

    All Memories

    } obsEl.innerHTML = obs.map(o => `
    -
    ${badge(o.type)} #${o.id} ${fmtTime(o.created_at)}${o.project ? `@ ${o.project}` : ""}
    +
    ${badge(o.type)} #${o.id} ${fmtTime(o.created_at)}${o.project ? `@ ${o.project}` : ""}${o.author ? `✎ ${escapeHtml(o.author)}` : ""}
    ${escapeHtml(o.title)}
    ${escapeHtml((o.content||'').slice(0,200))}
    @@ -202,19 +247,93 @@

    All Memories

    function escapeHtml(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } +/* tiny markdown: headings, bold, italic, code, links, lists, blockquote, hr, tables, wiki-links — safe, no deps */ +function mdInline(raw) { + let s = escapeHtml(raw); + s = s.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => `[[${b||a}]]`); + s = s.replace(/`([^`\n]+)`/g, (_, c) => `${c}`); + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`); + s = s.replace(/\*\*\*([^*]+)\*\*\*/g, '$1'); + s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + s = s.replace(/__([^_\n]+)__/g, '$1'); + s = s.replace(/(^|\W)\*([^\*\n]+)\*(?=\W|$)/g, (m, p, c) => `${p}${c}`); + s = s.replace(/(^|\W)_([^_\n]+)_(?=\W|$)/g, (m, p, c) => `${p}${c}`); + return s; +} +function mdBlock(md) { + md = md.replace(/\r\n/g, '\n'); + const esc = (t) => t.replace(/&/g,'&').replace(//g,'>'); + const lines = md.split('\n'); + let out = '', i = 0; + // fenced ``` blocks + let fenced = null; + const flushFenced = () => { if (fenced !== null) { out += `
    ${esc(fenced.join('\n'))}
    `; fenced = null; } }; + const flushList = (items, ordered) => { + if (!items.length) return; + const tag = ordered ? 'ol' : 'ul'; + out += `<${tag}>` + items.map(li => `
  • ${mdInline(li)}
  • `).join('') + ``; + }; + let listBuf = [], listOrdered = false; + const flushLists = () => { if (listBuf.length) { flushList(listBuf, listOrdered); listBuf = []; } }; + for (; i < lines.length; i++) { + let line = lines[i]; + if (line.startsWith('```')) { if (fenced === null) { flushLists(); fenced = []; } else { flushFenced(); } continue; } + if (fenced !== null) { fenced.push(line); continue; } + if (/^\s*$/.test(line)) { flushLists(); continue; } + if (/^\s*---+\s*$/.test(line)) { flushLists(); out += '
    '; continue; } + const bq = line.match(/^\s*>\s?(.*)$/); + if (bq) { flushLists(); let bqLines=[]; while(i\s?(.*)$/); if(!m) break; bqLines.push(mdInline(m[1])); i++;} i--; out += `
    ${bqLines.join('
    ')}
    `; continue; } + const tableRow = /^\s*\|.*\|\s*$/.test(line); + if (tableRow) { flushLists(); let rows=[]; while(i=2 && /^\s*\|[-| :]+\|\s*$/.test(rows[1])){const cells=c=>c.split('|').slice(1,-1).map(x=>x.trim()); const heads=cells(rows[0]); out+=``+heads.map(h=>``).join('')+``+rows.slice(2).map(r=>``+cells(r).map(c=>``).join('')+``).join('')+`
    ${mdInline(h)}
    ${mdInline(c)}
    `;} else { rows.forEach(r=> out+=`

    ${mdInline(r)}

    `);} continue; } + const h = line.match(/^(#{1,4})\s+(.*)$/); + if (h) { flushLists(); out += `${mdInline(h[2])}`; continue; } + const ul = line.match(/^\s*[-*]\s+(.*)$/); + const ol = line.match(/^\s*\d+\.\s+(.*)$/); + if (ul || ol) { const ordered = !!ol; const txt = (ul?ul[1]:ol[1]); if (listBuf.length && ordered !== listOrdered) { flushLists(); } listOrdered = ordered; listBuf.push(txt); continue; } + flushLists(); out += `

    ${mdInline(line)}

    `; + } + flushFenced(); flushLists(); + return out || '

    '; +} + +function chip(k, v, extraClass) { + if (!v) return ""; + const cls = extraClass ? ` meta-chip ${extraClass}` : " meta-chip"; + const mono = k === "id" || k === "session" ? " mono" : ""; + return `${k}${escapeHtml(String(v))}`; +} +function renderDetailMeta(o) { + const parts = []; + parts.push(chip("id", o.id)); + parts.push(chip("scope", o.scope, "scope")); + parts.push(chip("created", fmtTime(o.created_at))); + if (o.updated_at && o.updated_at !== o.created_at) parts.push(chip("updated", fmtTime(o.updated_at))); + if (o.project) parts.push(chip("project", o.project, "project")); + if (o.topic_key) parts.push(chip("topic", o.topic_key)); + if (o.author) parts.push(chip("author", o.author, "author")); + parts.push(chip("session", o.session_id)); + if (o.sync_id) parts.push(chip("sync", o.sync_id.slice(0,18))); + if (o.revision_count != null) parts.push(chip("revisions", o.revision_count)); + if (o.duplicate_count != null) parts.push(chip("dups", o.duplicate_count)); + if (o.last_seen_at) parts.push(chip("seen", fmtTime(o.last_seen_at))); + return parts.join(""); +} + async function showDetail(id) { try { const o = await api(`/observations/${id}`); currentObs = o; - const panel = document.getElementById("detail-panel"); document.getElementById("detail-title").textContent = o.title; - document.getElementById("detail-meta").innerHTML = `${badge(o.type)} #${o.id} ${fmtTime(o.created_at)} updated ${fmtTime(o.updated_at)} ${o.project ? `@ ${o.project}` : ""} · scope: ${o.scope} · session: ${o.session_id}`; - document.getElementById("detail-content").textContent = o.content; - panel.classList.add("open"); + document.getElementById("detail-type-badge").innerHTML = badge(o.type); + document.getElementById("detail-meta").innerHTML = renderDetailMeta(o); + document.getElementById("detail-content").innerHTML = mdBlock(o.content || ''); + document.getElementById("detail-panel").classList.add("open"); } catch(e) { alert("Failed to load observation"); } } function closeDetail() { document.getElementById("detail-panel").classList.remove("open"); } +document.getElementById("detail-panel").addEventListener("click", (e) => { if (e.target === e.currentTarget) closeDetail(); }); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); }); function editObservation(o) { if (!o) return; diff --git a/internal/server/server.go b/internal/server/server.go index ff5c0f010..203ee2162 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -199,6 +199,12 @@ 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 @@ -245,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 @@ -817,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 de6aa1051..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"` @@ -190,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"` @@ -256,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"` @@ -711,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, @@ -824,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"}, @@ -2354,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 @@ -3573,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, @@ -3582,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))), @@ -6061,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, ) } @@ -6254,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/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": From d0ad770dc836741c2b2f0a08b3973ff9bbd9b180 Mon Sep 17 00:00:00 2001 From: Faturrachman Date: Sat, 5 Sep 2026 03:48:16 +0700 Subject: [PATCH 6/6] docs(handoff): add cross-machine maintainer context --- HANDOFF.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 88 insertions(+) create mode 100644 HANDOFF.md 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 f1ea80aeb..1109ba960 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Architecture • Plugins • Team Usage • + Maintainer Handoff • Contributing • Full Docs

    @@ -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) |