Skip to content

Commit 402fd40

Browse files
Merge pull request #311 from Gentleman-Programming/fix/prompt-capture-judgment
fix(mcp): harden prompt capture follow-up
2 parents 9127f7b + 2b9d1ab commit 402fd40

5 files changed

Lines changed: 127 additions & 15 deletions

File tree

DOCS.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ This is the complete technical reference for Engram. For getting started, see th
2121
| [Features](#features) | FTS5 search, timeline, privacy, git sync, compression |
2222
| [TUI](#terminal-ui-tui) | Screens, navigation, architecture |
2323
| [Running as a Service](#running-as-a-service) | systemd setup |
24-
| [Design Decisions](#design-decisions) | Why Go, why SQLite, why no auto-capture |
24+
| [Design Decisions](#design-decisions) | Why Go, why SQLite, why no raw auto-capture |
2525

2626
For other docs:
2727

@@ -547,9 +547,11 @@ Every successful tool response includes these fields:
547547

548548
Error responses include `available_projects` when the error is `ambiguous_project` or `unknown_project`.
549549

550-
### Write tools (no project arg)
550+
### Write tools (cwd-detected project, limited recovery override)
551551

552-
`mem_save`, `mem_save_prompt`, `mem_session_start`, `mem_session_end`, `mem_session_summary`, `mem_capture_passive`, `mem_update` — project is auto-detected from cwd. Any `project` argument the LLM sends is silently discarded.
552+
`mem_session_start`, `mem_session_end`, `mem_session_summary`, `mem_capture_passive`, and `mem_update` auto-detect project from cwd. Any `project` argument the LLM sends is ignored.
553+
554+
`mem_save` and `mem_save_prompt` also auto-detect project from cwd by default, but they accept one narrow recovery override: after a previous `ambiguous_project` error, the agent may retry with `project=<one of available_projects>` and `project_choice_reason=user_selected_after_ambiguous_project`. Without that exact reason, LLM-supplied `project` is ignored so routine writes cannot drift across project names.
553555

554556
### Read tools (optional project override)
555557

@@ -605,7 +607,7 @@ Save structured observations. The tool description teaches agents the format:
605607
- **type**: `decision` | `architecture` | `bugfix` | `pattern` | `config` | `discovery` | `learning`
606608
- **scope**: `project` (default) | `personal`
607609
- **topic_key**: optional canonical topic id (e.g. `architecture/auth-model`) used to upsert evolving memories
608-
- **capture_prompt**: optional boolean, default `true`; when current prompt context is available for the same project/session, Engram records it alongside the observation. Automated pipeline saves such as SDD artifacts should pass `false`.
610+
- **capture_prompt**: optional boolean, default `true`; when current prompt context is available in the same MCP process for the same project/session, Engram best-effort records it alongside the observation. If that process-local context is unavailable or prompt capture fails, `mem_save` still succeeds. Automated pipeline saves such as SDD artifacts should pass `false`.
609611
- **content**: Structured with `**What**`, `**Why**`, `**Where**`, `**Learned**`
610612

611613
Exact duplicate saves are deduplicated in a rolling time window using a normalized content hash + project + scope + type + title.
@@ -626,7 +628,7 @@ Delete an observation by ID. Uses soft-delete by default (`deleted_at`); optiona
626628
### mem_save_prompt
627629

628630
Save user prompts — records what the user asked so future sessions have context about user goals.
629-
When called in the same MCP process, this also feeds the current prompt context used by later `mem_save` calls with `capture_prompt=true`.
631+
When called in the same MCP process, this also feeds process-local current prompt context used by later `mem_save` calls with `capture_prompt=true`. The same MCP process lifecycle must receive the prompt context before the later save; prompt capture is best-effort and `mem_save` still succeeds when no context is available.
630632

631633
### mem_context
632634

@@ -909,9 +911,11 @@ Instead of a separate LLM service, the agent itself compresses observations. The
909911
- **Per-action** (`mem_save`): Structured summaries (What/Why/Where/Learned)
910912
- **Session summary** (`mem_session_summary`): Comprehensive end-of-session summary (Goal/Instructions/Discoveries/Accomplished/Files)
911913

912-
### No Raw Auto-Capture
914+
### No Raw Tool-Call Auto-Capture
915+
916+
Engram does not record a firehose of raw tool calls. Raw tool calls (`edit: {file: "foo.go"}`, `bash: {command: "go build"}`) are noisy and pollute FTS5 search. The agent's curated summaries are higher signal, more searchable, and don't bloat the database. Shell history and git provide the raw audit trail.
913917

914-
All memory comes from the agent itself — no firehose of raw tool calls. Why? Raw tool calls (`edit: {file: "foo.go"}`, `bash: {command: "go build"}`) are noisy and pollute FTS5 search. The agent's curated summaries are higher signal, more searchable, and don't bloat the database. Shell history and git provide the raw audit trail.
918+
Since v1.15.3, `mem_save` can also best-effort attach the current user prompt when prompt context was already provided to the same MCP process for the same project/session (typically by `mem_save_prompt`) and `capture_prompt` is not disabled. That is not raw event capture: it stores user intent tied to a curated save, and the save still succeeds if prompt context is missing.
915919

916920
---
917921

@@ -988,7 +992,7 @@ WantedBy=default.target
988992
4. **Agent-driven compression** — The agent already has an LLM. No separate compression service.
989993
5. **Privacy at two layers** — Strip in plugin AND store. Defense in depth.
990994
6. **Pure Go SQLite (modernc.org/sqlite)** — No CGO means true cross-platform binary distribution.
991-
7. **No raw auto-capture** — The agent saves curated summaries. Shell history and git provide the raw audit trail.
995+
7. **No raw tool-call auto-capture** — The agent saves curated summaries; `mem_save` may best-effort capture process-local prompt context tied to that save, but Engram does not ingest raw tool-call firehoses. Shell history and git provide the raw audit trail.
992996
8. **TUI with Bubbletea** — Interactive terminal UI following Gentleman Bubbletea patterns.
993997

994998
---

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Next session starts → Previous session context is injected automatically
5353

5454
| Tool | Purpose |
5555
|------|---------|
56-
| `mem_save` | Save a structured observation (decision, bugfix, pattern, etc.); automatically captures the current prompt when one is available unless `capture_prompt=false` |
56+
| `mem_save` | Save a structured observation (decision, bugfix, pattern, etc.); best-effort captures process-local current prompt context when available unless `capture_prompt=false` |
5757
| `mem_update` | Update an existing observation by ID |
5858
| `mem_delete` | Delete an observation (soft-delete by default, hard-delete optional) |
5959
| `mem_suggest_topic_key` | Suggest a stable `topic_key` for evolving topics before saving |
@@ -88,7 +88,7 @@ Token-efficient memory retrieval — don't dump everything, drill in:
8888

8989
- `mem_save` now supports `scope` (`project` default, `personal` optional)
9090
- `mem_save` also supports `topic_key`; with a topic key, saves become upserts (same project+scope+topic updates the existing memory)
91-
- `mem_save` supports `capture_prompt` (`true` by default). When the MCP process has current prompt context for the same project and session, it records that prompt alongside the observation. Automated saves such as SDD artifacts should pass `capture_prompt=false`.
91+
- `mem_save` supports `capture_prompt` (`true` by default). When the same MCP process lifecycle has current prompt context for the same project and session, it best-effort records that prompt alongside the observation. The prompt context must be fed before the later `mem_save` (typically via `mem_save_prompt`); `mem_save` still succeeds if context is unavailable or prompt capture fails. Automated saves such as SDD artifacts should pass `capture_prompt=false`.
9292
- Exact dedupe prevents repeated inserts in a rolling window (hash + project + scope + type + title)
9393
- Duplicates update metadata (`duplicate_count`, `last_seen_at`, `updated_at`) instead of creating new rows
9494
- Topic upserts increment `revision_count` so evolving decisions stay in one memory

docs/PLUGINS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ The plugin:
3939
- **Strips `<private>` tags** before sending data
4040
- **Enables** `opencode-subagent-statusline` in `tui.json` or `tui.jsonc` during `engram setup opencode`, adding a live sub-agent monitor to OpenCode's sidebar/home footer. To disable it later, remove `"opencode-subagent-statusline"` from the `"plugin"` array in your TUI config and restart OpenCode.
4141

42-
**No raw tool call recording** — the agent handles all memory via `mem_save` and `mem_session_summary`.
42+
**No raw tool call recording** — the agent handles memory through curated saves such as `mem_save` and `mem_session_summary`. `mem_save` may best-effort attach prompt context, but only when that prompt was already fed to the same MCP process lifecycle.
4343

4444
### Memory Protocol (injected via system prompt)
4545

@@ -258,9 +258,9 @@ Old clients that read only the `result` string continue to work — these fields
258258

259259
### mem_save prompt capture
260260

261-
`mem_save` accepts `capture_prompt` as an optional boolean. The default is `true`: if the MCP process already has the current user prompt for the same project and session, Engram stores it in `user_prompts` using exact project + session + content dedupe. Passing `capture_prompt=false` skips that prompt capture path and is intended for automated artifacts such as SDD progress saves.
261+
`mem_save` accepts `capture_prompt` as an optional boolean. The default is `true`: if the same MCP process lifecycle already has the current user prompt for the same project and session, Engram best-effort stores it in `user_prompts` using exact project + session + content dedupe. Passing `capture_prompt=false` skips that prompt capture path and is intended for automated artifacts such as SDD progress saves.
262262

263-
If no current prompt is available to the MCP process, `mem_save` still succeeds and no prompt is invented from the observation content. Plugins/protocol hooks that can observe user prompts must feed that prompt context before relying on automatic capture. Calling `mem_save_prompt` in the same MCP process records the prompt and makes it available to later `mem_save` calls for the same project/session.
263+
If no current prompt is available to the MCP process, or if best-effort prompt capture fails, `mem_save` still succeeds and no prompt is invented from the observation content. Plugins/protocol hooks that can observe user prompts must feed that prompt context before relying on automatic capture. Calling `mem_save_prompt` in the same MCP process records the prompt and makes it available to later `mem_save` calls for the same project/session; a different MCP process lifecycle does not inherit that in-memory prompt context.
264264

265265
---
266266

internal/mcp/mcp.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ type MCPConfig struct {
5353

5454
var suggestTopicKey = store.SuggestTopicKey
5555

56+
var addPromptIfMissing = func(s *store.Store, params store.AddPromptParams) (int64, bool, error) {
57+
return s.AddPromptIfMissing(params)
58+
}
59+
5660
var loadMCPStats = func(s *store.Store) (*store.Stats, error) {
5761
return s.Stats()
5862
}
@@ -1077,7 +1081,7 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
10771081

10781082
if capturePrompt && activity != nil {
10791083
if prompt, ok := activity.CurrentPrompt(sessionID, project); ok {
1080-
if _, _, promptErr := s.AddPromptIfMissing(store.AddPromptParams{
1084+
if _, _, promptErr := addPromptIfMissing(s, store.AddPromptParams{
10811085
SessionID: sessionID,
10821086
Content: prompt,
10831087
Project: project,
@@ -1087,7 +1091,9 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
10871091
}
10881092
}
10891093

1090-
activity.RecordSave(defaultSessionID(project))
1094+
if activity != nil {
1095+
activity.RecordSave(sessionID)
1096+
}
10911097

10921098
msg := fmt.Sprintf("Memory saved: %q (%s)", title, typ)
10931099
if topicKey == "" && suggestedTopicKey != "" {

internal/mcp/mcp_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ func assertSessionSyncMutationDirectory(t *testing.T, s *store.Store, sessionID,
7272
t.Fatalf("expected pending session upsert sync mutation for %q; got %#v", sessionID, mutations)
7373
}
7474

75+
func countPromptUpsertSyncMutations(t *testing.T, s *store.Store) int {
76+
t.Helper()
77+
78+
mutations, err := s.ListPendingSyncMutations(store.DefaultSyncTargetKey, 100)
79+
if err != nil {
80+
t.Fatalf("list pending sync mutations: %v", err)
81+
}
82+
83+
count := 0
84+
for _, mutation := range mutations {
85+
if mutation.Entity == store.SyncEntityPrompt && mutation.Op == store.SyncOpUpsert {
86+
count++
87+
}
88+
}
89+
return count
90+
}
91+
7592
func TestNewServerRegistersTools(t *testing.T) {
7693
s := newMCPTestStore(t)
7794
srv := NewServer(s)
@@ -141,6 +158,9 @@ func TestHandleSaveSuggestsTopicKeyWhenMissing(t *testing.T) {
141158

142159
func TestHandleSaveAutoCapturesCurrentPromptByDefault(t *testing.T) {
143160
s := newMCPTestStore(t)
161+
if err := s.EnrollProject("engram"); err != nil {
162+
t.Fatalf("enroll project: %v", err)
163+
}
144164
activity := NewSessionActivity(10 * time.Minute)
145165
sessionID := defaultSessionID("engram")
146166
activity.RecordPrompt(sessionID, "engram", "please persist the auth decision")
@@ -185,6 +205,88 @@ func TestHandleSaveAutoCapturesCurrentPromptByDefault(t *testing.T) {
185205
if len(prompts) != 1 {
186206
t.Fatalf("expected prompt dedupe to keep one row, got %d: %#v", len(prompts), prompts)
187207
}
208+
if got := countPromptUpsertSyncMutations(t, s); got != 1 {
209+
t.Fatalf("expected prompt dedupe to keep one prompt sync mutation, got %d", got)
210+
}
211+
}
212+
213+
func TestHandleSaveRecordsActivityForExplicitSessionID(t *testing.T) {
214+
s := newMCPTestStore(t)
215+
activity := NewSessionActivity(10 * time.Minute)
216+
h := handleSave(s, MCPConfig{}, activity)
217+
218+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
219+
"title": "Explicit session save",
220+
"content": "**What**: saved with explicit session\n**Why**: regression test",
221+
"type": "bugfix",
222+
"project": "engram",
223+
"session_id": "custom-session-123",
224+
}}})
225+
if err != nil {
226+
t.Fatalf("handler error: %v", err)
227+
}
228+
if res.IsError {
229+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
230+
}
231+
232+
if got := activity.ActivityScore("custom-session-123"); !strings.Contains(got, "1 save") {
233+
t.Fatalf("expected explicit session activity to record save, got %q", got)
234+
}
235+
if got := activity.ActivityScore(defaultSessionID("engram")); got != "" {
236+
t.Fatalf("expected default session activity to remain untouched, got %q", got)
237+
}
238+
}
239+
240+
func TestHandleSaveWithNilActivityStillSucceeds(t *testing.T) {
241+
s := newMCPTestStore(t)
242+
h := handleSave(s, MCPConfig{}, nil)
243+
244+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
245+
"title": "Nil activity save",
246+
"content": "**What**: saved without activity tracker\n**Why**: regression test",
247+
"type": "bugfix",
248+
"project": "engram",
249+
}}})
250+
if err != nil {
251+
t.Fatalf("handler error: %v", err)
252+
}
253+
if res.IsError {
254+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
255+
}
256+
}
257+
258+
func TestHandleSavePromptCaptureFailureIsNonFatal(t *testing.T) {
259+
s := newMCPTestStore(t)
260+
activity := NewSessionActivity(10 * time.Minute)
261+
activity.RecordPrompt(defaultSessionID("engram"), "engram", "prompt capture should fail non-fatally")
262+
h := handleSave(s, MCPConfig{}, activity)
263+
264+
originalAddPromptIfMissing := addPromptIfMissing
265+
addPromptIfMissing = func(*store.Store, store.AddPromptParams) (int64, bool, error) {
266+
return 0, false, errors.New("forced prompt capture failure")
267+
}
268+
t.Cleanup(func() { addPromptIfMissing = originalAddPromptIfMissing })
269+
270+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
271+
"title": "Non fatal prompt capture",
272+
"content": "**What**: saved despite prompt capture failure\n**Why**: regression test",
273+
"type": "bugfix",
274+
"project": "engram",
275+
}}})
276+
if err != nil {
277+
t.Fatalf("handler error: %v", err)
278+
}
279+
if res.IsError {
280+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
281+
}
282+
283+
obs, err := s.RecentObservations("engram", "project", 5)
284+
if err != nil {
285+
t.Fatalf("recent observations: %v", err)
286+
}
287+
if len(obs) != 1 || obs[0].Title != "Non fatal prompt capture" {
288+
t.Fatalf("expected observation to be saved despite prompt capture failure, got %#v", obs)
289+
}
188290
}
189291

190292
func TestHandleSavePromptFeedsAutoCaptureContext(t *testing.T) {

0 commit comments

Comments
 (0)