Skip to content

Commit 9127f7b

Browse files
Merge pull request #309 from Gentleman-Programming/feat/auto-capture-save-prompts
feat(mcp): auto-capture prompt context on save
2 parents 99d3018 + a9b83d6 commit 9127f7b

7 files changed

Lines changed: 288 additions & 13 deletions

File tree

DOCS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ Save structured observations. The tool description teaches agents the format:
605605
- **type**: `decision` | `architecture` | `bugfix` | `pattern` | `config` | `discovery` | `learning`
606606
- **scope**: `project` (default) | `personal`
607607
- **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`.
608609
- **content**: Structured with `**What**`, `**Why**`, `**Where**`, `**Learned**`
609610

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

627628
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`.
628630

629631
### mem_context
630632

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
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.) |
56+
| `mem_save` | Save a structured observation (decision, bugfix, pattern, etc.); automatically captures the current prompt when one is 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,6 +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`.
9192
- Exact dedupe prevents repeated inserts in a rolling window (hash + project + scope + type + title)
9293
- Duplicates update metadata (`duplicate_count`, `last_seen_at`, `updated_at`) instead of creating new rows
9394
- Topic upserts increment `revision_count` so evolving decisions stay in one memory

docs/PLUGINS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,12 @@ When `mem_save` detects candidates, the JSON response includes:
256256

257257
Old clients that read only the `result` string continue to work — these fields are additive.
258258

259+
### mem_save prompt capture
260+
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.
262+
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.
264+
259265
---
260266

261267
## Admin Observability (conflict layer)

internal/mcp/activity.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ type sessionState struct {
1919
toolCallCount int
2020
saveCount int
2121
startedAt time.Time
22+
currentPrompt *promptContext
23+
}
24+
25+
type promptContext struct {
26+
project string
27+
content string
2228
}
2329

2430
// NewSessionActivity creates a new activity tracker with the given nudge threshold.
@@ -63,6 +69,31 @@ func (a *SessionActivity) RecordSave(sessionID string) {
6369
s.lastSaveAt = a.now()
6470
}
6571

72+
// RecordPrompt stores the latest user prompt observed for a session. MCP does
73+
// not currently receive user prompts on every tool call, so callers must feed
74+
// this explicitly when prompt text is available.
75+
func (a *SessionActivity) RecordPrompt(sessionID, project, content string) {
76+
a.mu.Lock()
77+
defer a.mu.Unlock()
78+
s := a.getOrCreate(sessionID)
79+
s.currentPrompt = &promptContext{project: project, content: content}
80+
}
81+
82+
// CurrentPrompt returns the latest prompt for the session when it belongs to the
83+
// same project as the save operation.
84+
func (a *SessionActivity) CurrentPrompt(sessionID, project string) (string, bool) {
85+
a.mu.Lock()
86+
defer a.mu.Unlock()
87+
s, ok := a.sessions[sessionID]
88+
if !ok || s.currentPrompt == nil {
89+
return "", false
90+
}
91+
if s.currentPrompt.project != project || s.currentPrompt.content == "" {
92+
return "", false
93+
}
94+
return s.currentPrompt.content, true
95+
}
96+
6697
// NudgeIfNeeded returns a reminder string if too much time has passed since
6798
// the last save in this session. Returns empty string if no nudge needed.
6899
func (a *SessionActivity) NudgeIfNeeded(sessionID string) string {

internal/mcp/mcp.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ Examples:
335335
mcp.WithString("project_choice_reason",
336336
mcp.Description("Must be user_selected_after_ambiguous_project, and only after the user explicitly chose one of available_projects from an ambiguous_project error."),
337337
),
338+
mcp.WithBoolean("capture_prompt",
339+
mcp.Description("Automatically capture the current user prompt when available (default: true). Set false for SDD artifacts or automated saves."),
340+
),
338341
),
339342
queuedWriteHandler(writeQueue, handleSave(s, cfg, activity)),
340343
)
@@ -447,7 +450,7 @@ Examples:
447450
mcp.Description("Must be user_selected_after_ambiguous_project, and only after the user explicitly chose one of available_projects from an ambiguous_project error."),
448451
),
449452
),
450-
queuedWriteHandler(writeQueue, handleSavePrompt(s, cfg)),
453+
queuedWriteHandler(writeQueue, handleSavePrompt(s, cfg, activity)),
451454
)
452455
}
453456

@@ -1011,6 +1014,7 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
10111014
topicKey, _ := req.GetArguments()["topic_key"].(string)
10121015
projectChoice, _ := req.GetArguments()["project"].(string)
10131016
projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string)
1017+
capturePrompt := boolArg(req, "capture_prompt", true)
10141018

10151019
// Auto-detect project from cwd; only allow explicit user-selected recovery
10161020
// after ErrAmbiguousProject (issue #306).
@@ -1071,6 +1075,18 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
10711075
return mcp.NewToolResultError("Failed to save: " + err.Error()), nil
10721076
}
10731077

1078+
if capturePrompt && activity != nil {
1079+
if prompt, ok := activity.CurrentPrompt(sessionID, project); ok {
1080+
if _, _, promptErr := s.AddPromptIfMissing(store.AddPromptParams{
1081+
SessionID: sessionID,
1082+
Content: prompt,
1083+
Project: project,
1084+
}); promptErr != nil {
1085+
fmt.Fprintf(os.Stderr, "engram: auto prompt capture error (non-fatal): %v\n", promptErr)
1086+
}
1087+
}
1088+
}
1089+
10741090
activity.RecordSave(defaultSessionID(project))
10751091

10761092
msg := fmt.Sprintf("Memory saved: %q (%s)", title, typ)
@@ -1243,7 +1259,7 @@ func handleDelete(s *store.Store) server.ToolHandlerFunc {
12431259
}
12441260
}
12451261

1246-
func handleSavePrompt(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
1262+
func handleSavePrompt(s *store.Store, cfg MCPConfig, activity *SessionActivity) server.ToolHandlerFunc {
12471263
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
12481264
content, _ := req.GetArguments()["content"].(string)
12491265
sessionID, _ := req.GetArguments()["session_id"].(string)
@@ -1272,6 +1288,10 @@ func handleSavePrompt(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
12721288
return mcp.NewToolResultError("Failed to save prompt: " + err.Error()), nil
12731289
}
12741290

1291+
if activity != nil {
1292+
activity.RecordPrompt(sessionID, project, content)
1293+
}
1294+
12751295
detRes.Project = project
12761296
return respondWithProject(detRes, fmt.Sprintf("Prompt saved: %q", truncate(content, 80)), nil), nil
12771297
}

internal/mcp/mcp_test.go

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,150 @@ func TestHandleSaveSuggestsTopicKeyWhenMissing(t *testing.T) {
139139
}
140140
}
141141

142+
func TestHandleSaveAutoCapturesCurrentPromptByDefault(t *testing.T) {
143+
s := newMCPTestStore(t)
144+
activity := NewSessionActivity(10 * time.Minute)
145+
sessionID := defaultSessionID("engram")
146+
activity.RecordPrompt(sessionID, "engram", "please persist the auth decision")
147+
h := handleSave(s, MCPConfig{}, activity)
148+
149+
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
150+
"title": "Auth decision",
151+
"content": "**What**: chose auth boundary\n**Why**: user asked",
152+
"type": "decision",
153+
"project": "engram",
154+
}}}
155+
156+
res, err := h(context.Background(), req)
157+
if err != nil {
158+
t.Fatalf("handler error: %v", err)
159+
}
160+
if res.IsError {
161+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
162+
}
163+
164+
prompts, err := s.RecentPrompts("engram", 5)
165+
if err != nil {
166+
t.Fatalf("recent prompts: %v", err)
167+
}
168+
if len(prompts) != 1 {
169+
t.Fatalf("expected one auto-captured prompt, got %d: %#v", len(prompts), prompts)
170+
}
171+
if prompts[0].SessionID != sessionID || prompts[0].Content != "please persist the auth decision" {
172+
t.Fatalf("unexpected prompt row: %#v", prompts[0])
173+
}
174+
175+
// Saving another observation in the same session should reuse the prompt row,
176+
// not duplicate exact same project+session+content context.
177+
res, err = h(context.Background(), req)
178+
if err != nil || res.IsError {
179+
t.Fatalf("second save failed: err=%v isError=%v text=%q", err, res.IsError, callResultText(t, res))
180+
}
181+
prompts, err = s.RecentPrompts("engram", 5)
182+
if err != nil {
183+
t.Fatalf("recent prompts after second save: %v", err)
184+
}
185+
if len(prompts) != 1 {
186+
t.Fatalf("expected prompt dedupe to keep one row, got %d: %#v", len(prompts), prompts)
187+
}
188+
}
189+
190+
func TestHandleSavePromptFeedsAutoCaptureContext(t *testing.T) {
191+
s := newMCPTestStore(t)
192+
activity := NewSessionActivity(10 * time.Minute)
193+
savePrompt := handleSavePrompt(s, MCPConfig{}, activity)
194+
save := handleSave(s, MCPConfig{}, activity)
195+
196+
promptRes, err := savePrompt(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
197+
"content": "user asked for prompt-linked bugfix memory",
198+
"project": "engram",
199+
}}})
200+
if err != nil {
201+
t.Fatalf("save prompt handler error: %v", err)
202+
}
203+
if promptRes.IsError {
204+
t.Fatalf("unexpected save prompt error: %s", callResultText(t, promptRes))
205+
}
206+
207+
saveRes, err := save(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
208+
"title": "Prompt linked bugfix",
209+
"content": "**What**: linked prompt context\n**Why**: user asked",
210+
"type": "bugfix",
211+
"project": "engram",
212+
}}})
213+
if err != nil {
214+
t.Fatalf("save handler error: %v", err)
215+
}
216+
if saveRes.IsError {
217+
t.Fatalf("unexpected save error: %s", callResultText(t, saveRes))
218+
}
219+
220+
prompts, err := s.RecentPrompts("engram", 5)
221+
if err != nil {
222+
t.Fatalf("recent prompts: %v", err)
223+
}
224+
if len(prompts) != 1 {
225+
t.Fatalf("expected mem_save_prompt row to feed auto-capture without duplicate, got %d: %#v", len(prompts), prompts)
226+
}
227+
if prompts[0].Content != "user asked for prompt-linked bugfix memory" {
228+
t.Fatalf("unexpected prompt content: %#v", prompts[0])
229+
}
230+
}
231+
232+
func TestHandleSaveCapturePromptFalseSkipsCurrentPrompt(t *testing.T) {
233+
s := newMCPTestStore(t)
234+
activity := NewSessionActivity(10 * time.Minute)
235+
activity.RecordPrompt(defaultSessionID("engram"), "engram", "do not capture this prompt")
236+
h := handleSave(s, MCPConfig{}, activity)
237+
238+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
239+
"title": "SDD artifact",
240+
"content": "## Apply progress",
241+
"type": "architecture",
242+
"project": "engram",
243+
"capture_prompt": false,
244+
}}})
245+
if err != nil {
246+
t.Fatalf("handler error: %v", err)
247+
}
248+
if res.IsError {
249+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
250+
}
251+
252+
prompts, err := s.RecentPrompts("engram", 5)
253+
if err != nil {
254+
t.Fatalf("recent prompts: %v", err)
255+
}
256+
if len(prompts) != 0 {
257+
t.Fatalf("expected opt-out to skip prompt capture, got %#v", prompts)
258+
}
259+
}
260+
261+
func TestHandleSaveNoCurrentPromptStillSucceeds(t *testing.T) {
262+
s := newMCPTestStore(t)
263+
h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
264+
265+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
266+
"title": "No prompt available",
267+
"content": "**What**: saved without prompt context",
268+
"type": "discovery",
269+
"project": "engram",
270+
}}})
271+
if err != nil {
272+
t.Fatalf("handler error: %v", err)
273+
}
274+
if res.IsError {
275+
t.Fatalf("unexpected save error: %s", callResultText(t, res))
276+
}
277+
prompts, err := s.RecentPrompts("engram", 5)
278+
if err != nil {
279+
t.Fatalf("recent prompts: %v", err)
280+
}
281+
if len(prompts) != 0 {
282+
t.Fatalf("expected no prompt rows when no current prompt is available, got %#v", prompts)
283+
}
284+
}
285+
142286
func TestHandleSaveDoesNotSuggestWhenTopicKeyProvided(t *testing.T) {
143287
s := newMCPTestStore(t)
144288
h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
@@ -412,7 +556,7 @@ func TestHandlePromptContextStatsTimelineAndSessionHandlers(t *testing.T) {
412556
t.Fatalf("add observation: %v", err)
413557
}
414558

415-
savePrompt := handleSavePrompt(s, MCPConfig{})
559+
savePrompt := handleSavePrompt(s, MCPConfig{}, nil)
416560
savePromptReq := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
417561
"content": "how do we fix auth race conditions?",
418562
"project": "engram",
@@ -626,7 +770,7 @@ func TestMCPHandlersReturnErrorsWhenStoreClosed(t *testing.T) {
626770
t.Fatalf("expected delete to return tool error when store is closed")
627771
}
628772

629-
promptRes, err := handleSavePrompt(s, MCPConfig{})(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"content": "prompt", "project": "engram"}}})
773+
promptRes, err := handleSavePrompt(s, MCPConfig{}, nil)(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"content": "prompt", "project": "engram"}}})
630774
if err != nil {
631775
t.Fatalf("closed store save prompt call: %v", err)
632776
}
@@ -1896,7 +2040,7 @@ func TestHandleSaveCreatesProjectScopedSession(t *testing.T) {
18962040

18972041
func TestHandleSavePromptCreatesProjectScopedSession(t *testing.T) {
18982042
s := newMCPTestStore(t)
1899-
h := handleSavePrompt(s, MCPConfig{})
2043+
h := handleSavePrompt(s, MCPConfig{}, nil)
19002044

19012045
// Set up a git repo so auto-detect returns a known project.
19022046
dir := t.TempDir()
@@ -3130,7 +3274,7 @@ func TestMemSavePrompt_AmbiguousWithValidUserChoiceSucceeds(t *testing.T) {
31303274
t.Chdir(parent)
31313275

31323276
s := newMCPTestStore(t)
3133-
h := handleSavePrompt(s, MCPConfig{})
3277+
h := handleSavePrompt(s, MCPConfig{}, nil)
31343278
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
31353279
"content": "prompt after user chose repo-prompt-a",
31363280
"project": "repo-prompt-a",
@@ -3164,7 +3308,7 @@ func TestMemSavePrompt_AmbiguousWithInventedProjectRejected(t *testing.T) {
31643308
t.Chdir(parent)
31653309

31663310
s := newMCPTestStore(t)
3167-
h := handleSavePrompt(s, MCPConfig{})
3311+
h := handleSavePrompt(s, MCPConfig{}, nil)
31683312
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
31693313
"content": "prompt must not save",
31703314
"project": "invented-prompt-project",
@@ -3611,7 +3755,7 @@ func TestHandleSaveAndPromptUseConfigProjectForWrites(t *testing.T) {
36113755
t.Fatalf("expected mem_save config envelope, got %v", body)
36123756
}
36133757

3614-
prompt := handleSavePrompt(s, MCPConfig{})
3758+
prompt := handleSavePrompt(s, MCPConfig{}, nil)
36153759
res, err = prompt(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
36163760
"content": "prompt saved under config project",
36173761
"project": "attempted-override", "project_choice_reason": project.SourceUserSelectedAfterAmbiguousProject,
@@ -3932,6 +4076,20 @@ func TestMemSessionSummary_SchemaNoProjectField(t *testing.T) {
39324076
}
39334077
}
39344078

4079+
func TestMemSaveSchemaIncludesCapturePrompt(t *testing.T) {
4080+
s := newMCPTestStore(t)
4081+
srv := NewServer(s)
4082+
4083+
st := srv.GetTool("mem_save")
4084+
if st == nil {
4085+
t.Fatal("mem_save not registered")
4086+
}
4087+
props := st.Tool.InputSchema.Properties
4088+
if _, ok := props["capture_prompt"]; !ok {
4089+
t.Fatal("mem_save schema must include capture_prompt")
4090+
}
4091+
}
4092+
39354093
// TestMemSessionSummary_AutoDetectsProject: summary is stored under the auto-detected project.
39364094
func TestMemSessionSummary_AutoDetectsProject(t *testing.T) {
39374095
dir := t.TempDir()

0 commit comments

Comments
 (0)