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 @@
Architecture •
Plugins •
Team Usage •
+ Maintainer Handoff •
Contributing •
Full 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
+
+
+
+
+
+