Skip to content

Commit 1accb19

Browse files
Merge pull request #486 from Gentleman-Programming/fix/pi-mem-review-chrome
feat(pi): add native mem_review support
2 parents a968a5e + c2fc5f4 commit 1accb19

8 files changed

Lines changed: 280 additions & 2 deletions

File tree

DOCS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ Engram is local-first: local SQLite is authoritative; cloud features are optiona
142142
- `200` when deleted
143143
- `404` when observation does not exist
144144

145+
### Review
146+
147+
- `GET /review` — List observations due for local review. Query: `?project=X&limit=N`
148+
- `POST /review/mark_reviewed` — Reset one observation's local review cycle. Body: `{observation_id}`; legacy `{id}` is accepted.
149+
- `200` with the refreshed observation payload when marked reviewed
150+
- `400` when `observation_id`/`id` is missing or the JSON body is invalid
151+
- `404` when the observation does not exist
152+
- Local-only: updating `review_after` does not enqueue a sync mutation or propagate to other machines.
153+
145154
### Search
146155

147156
- `GET /search` — FTS5 search. Query: `?q=QUERY&type=TYPE&project=PROJECT&scope=SCOPE&limit=N`

internal/server/server.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ func (s *Server) routes() {
204204
s.mux.HandleFunc("GET /timeline", s.handleTimeline)
205205
s.mux.HandleFunc("GET /observations/{id}", s.handleGetObservation)
206206

207+
// Lifecycle review
208+
s.mux.HandleFunc("GET /review", s.handleReviewList)
209+
s.mux.HandleFunc("POST /review/mark_reviewed", s.handleReviewMarkReviewed)
210+
207211
// Prompts
208212
s.mux.HandleFunc("POST /prompts", s.handleAddPrompt)
209213
s.mux.HandleFunc("GET /prompts/recent", s.handleRecentPrompts)
@@ -508,6 +512,80 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
508512
jsonResponse(w, http.StatusOK, result)
509513
}
510514

515+
func (s *Server) handleReviewList(w http.ResponseWriter, r *http.Request) {
516+
project := r.URL.Query().Get("project")
517+
limit := queryInt(r, "limit", 10)
518+
519+
observations, err := s.store.ObservationsNeedingReview(project, limit)
520+
if err != nil {
521+
jsonError(w, http.StatusInternalServerError, err.Error())
522+
return
523+
}
524+
525+
structured := make([]map[string]any, 0, len(observations))
526+
for _, obs := range observations {
527+
structured = append(structured, reviewObservationPayload(obs))
528+
}
529+
530+
jsonResponse(w, http.StatusOK, map[string]any{
531+
"observations": structured,
532+
"count": len(structured),
533+
})
534+
}
535+
536+
func (s *Server) handleReviewMarkReviewed(w http.ResponseWriter, r *http.Request) {
537+
var body struct {
538+
ObservationID int64 `json:"observation_id"`
539+
ID int64 `json:"id"`
540+
}
541+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
542+
jsonError(w, http.StatusBadRequest, "invalid json: "+err.Error())
543+
return
544+
}
545+
546+
id := body.ObservationID
547+
if id == 0 {
548+
id = body.ID
549+
}
550+
if id == 0 {
551+
jsonError(w, http.StatusBadRequest, "observation_id is required")
552+
return
553+
}
554+
555+
if err := s.store.MarkReviewed(id); err != nil {
556+
if errors.Is(err, store.ErrObservationNotFound) {
557+
jsonError(w, http.StatusNotFound, err.Error())
558+
return
559+
}
560+
jsonError(w, http.StatusInternalServerError, err.Error())
561+
return
562+
}
563+
obs, err := s.store.GetObservation(id)
564+
if err != nil {
565+
jsonError(w, http.StatusInternalServerError, "marked reviewed but failed to reload observation: "+err.Error())
566+
return
567+
}
568+
569+
jsonResponse(w, http.StatusOK, reviewObservationPayload(*obs))
570+
}
571+
572+
func reviewObservationPayload(obs store.Observation) map[string]any {
573+
payload := map[string]any{
574+
"id": obs.ID,
575+
"sync_id": obs.SyncID,
576+
"title": obs.Title,
577+
"type": obs.Type,
578+
"state": obs.State(),
579+
}
580+
if obs.Project != nil {
581+
payload["project"] = *obs.Project
582+
}
583+
if obs.ReviewAfter != nil {
584+
payload["review_after"] = *obs.ReviewAfter
585+
}
586+
return payload
587+
}
588+
511589
// ─── Prompts ─────────────────────────────────────────────────────────────────
512590

513591
func (s *Server) handleAddPrompt(w http.ResponseWriter, r *http.Request) {

internal/server/server_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,108 @@ func TestAdditionalServerErrorBranches(t *testing.T) {
231231
}
232232
}
233233

234+
func TestHandleReviewListAndMarkReviewed(t *testing.T) {
235+
st := newServerTestStore(t)
236+
srv := New(st, 0)
237+
h := srv.Handler()
238+
239+
if err := st.CreateSession("s-http-review", "engram", "/tmp/engram"); err != nil {
240+
t.Fatalf("create session: %v", err)
241+
}
242+
id, err := st.AddObservation(store.AddObservationParams{SessionID: "s-http-review", Type: "decision", Title: "Review me", Content: "Needs lifecycle review", Project: "engram"})
243+
if err != nil {
244+
t.Fatalf("add observation: %v", err)
245+
}
246+
past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05")
247+
if _, err := st.DB().Exec(`UPDATE observations SET review_after = ? WHERE id = ?`, past, id); err != nil {
248+
t.Fatalf("backdate review_after: %v", err)
249+
}
250+
251+
listReq := httptest.NewRequest(http.MethodGet, "/review?project=engram&limit=5", nil)
252+
listRec := httptest.NewRecorder()
253+
h.ServeHTTP(listRec, listReq)
254+
if listRec.Code != http.StatusOK {
255+
t.Fatalf("expected review list 200, got %d body=%s", listRec.Code, listRec.Body.String())
256+
}
257+
var listBody map[string]any
258+
if err := json.NewDecoder(listRec.Body).Decode(&listBody); err != nil {
259+
t.Fatalf("decode review list: %v", err)
260+
}
261+
observations, ok := listBody["observations"].([]any)
262+
if !ok || len(observations) != 1 {
263+
t.Fatalf("expected one review observation, got %#v", listBody["observations"])
264+
}
265+
entry, _ := observations[0].(map[string]any)
266+
if entry["state"] != store.ObservationStateNeedsReview {
267+
t.Fatalf("expected needs_review state, got %v", entry["state"])
268+
}
269+
270+
markReq := httptest.NewRequest(http.MethodPost, "/review/mark_reviewed", strings.NewReader(fmt.Sprintf(`{"observation_id":%d}`, id)))
271+
markReq.Header.Set("Content-Type", "application/json")
272+
markRec := httptest.NewRecorder()
273+
h.ServeHTTP(markRec, markReq)
274+
if markRec.Code != http.StatusOK {
275+
t.Fatalf("expected mark reviewed 200, got %d body=%s", markRec.Code, markRec.Body.String())
276+
}
277+
var markBody map[string]any
278+
if err := json.NewDecoder(markRec.Body).Decode(&markBody); err != nil {
279+
t.Fatalf("decode mark reviewed: %v", err)
280+
}
281+
if markBody["state"] != store.ObservationStateActive {
282+
t.Fatalf("expected active after mark_reviewed, got %v", markBody["state"])
283+
}
284+
}
285+
286+
func TestHandleReviewMarkReviewedAcceptsIDAlias(t *testing.T) {
287+
st := newServerTestStore(t)
288+
srv := New(st, 0)
289+
h := srv.Handler()
290+
291+
if err := st.CreateSession("s-http-review-alias", "engram", "/tmp/engram"); err != nil {
292+
t.Fatalf("create session: %v", err)
293+
}
294+
id, err := st.AddObservation(store.AddObservationParams{SessionID: "s-http-review-alias", Type: "decision", Title: "Review alias", Content: "Needs lifecycle review", Project: "engram"})
295+
if err != nil {
296+
t.Fatalf("add observation: %v", err)
297+
}
298+
past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05")
299+
if _, err := st.DB().Exec(`UPDATE observations SET review_after = ? WHERE id = ?`, past, id); err != nil {
300+
t.Fatalf("backdate review_after: %v", err)
301+
}
302+
303+
req := httptest.NewRequest(http.MethodPost, "/review/mark_reviewed", strings.NewReader(fmt.Sprintf(`{"id":%d}`, id)))
304+
req.Header.Set("Content-Type", "application/json")
305+
rec := httptest.NewRecorder()
306+
h.ServeHTTP(rec, req)
307+
if rec.Code != http.StatusOK {
308+
t.Fatalf("expected mark reviewed alias 200, got %d body=%s", rec.Code, rec.Body.String())
309+
}
310+
}
311+
312+
func TestHandleReviewMarkReviewedRequiresObservationID(t *testing.T) {
313+
srv := New(newServerTestStore(t), 0)
314+
req := httptest.NewRequest(http.MethodPost, "/review/mark_reviewed", strings.NewReader(`{}`))
315+
req.Header.Set("Content-Type", "application/json")
316+
rec := httptest.NewRecorder()
317+
srv.Handler().ServeHTTP(rec, req)
318+
319+
if rec.Code != http.StatusBadRequest {
320+
t.Fatalf("expected 400 when observation_id is missing, got %d", rec.Code)
321+
}
322+
}
323+
324+
func TestHandleReviewMarkReviewedReturnsNotFoundForUnknownObservation(t *testing.T) {
325+
srv := New(newServerTestStore(t), 0)
326+
req := httptest.NewRequest(http.MethodPost, "/review/mark_reviewed", strings.NewReader(`{"observation_id":999999}`))
327+
req.Header.Set("Content-Type", "application/json")
328+
rec := httptest.NewRecorder()
329+
srv.Handler().ServeHTTP(rec, req)
330+
331+
if rec.Code != http.StatusNotFound {
332+
t.Fatalf("expected 404 for unknown observation, got %d body=%s", rec.Code, rec.Body.String())
333+
}
334+
}
335+
234336
func TestExportHonorsProjectQueryScope(t *testing.T) {
235337
st := newServerTestStore(t)
236338
srv := New(st, 0)

plugin/pi/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,29 @@ Pi events/tools -> gentle-engram extension -> ENGRAM_URL / engram serve -> SQLit
9999
Pi MCP tools -> pi-mcp-adapter -> ENGRAM_BIN / engram mcp -> SQLite
100100
```
101101

102-
Pi-native compact tools use the same HTTP server path as event capture, including project detection, diagnostics, passive capture, and conflict-judgment tools such as `mem_current_project`, `mem_doctor`, `mem_capture_passive`, `mem_judge`, and `mem_compare`. MCP tools remain a separate stdio path, so direct MCP usage still needs an Engram binary even when `ENGRAM_URL` points at a remote HTTP server. Engram MCP direct tools are not enabled by default in Pi to avoid duplicate raw `engram_mem_*` tool rows.
102+
Pi-native compact tools use the same HTTP server path as event capture, including project detection, diagnostics, passive capture, lifecycle review, and conflict-judgment tools such as `mem_current_project`, `mem_doctor`, `mem_capture_passive`, `mem_review`, `mem_judge`, and `mem_compare`. MCP tools remain a separate stdio path, so direct MCP usage still needs an Engram binary even when `ENGRAM_URL` points at a remote HTTP server. Engram MCP direct tools are not enabled by default in Pi to avoid duplicate raw `engram_mem_*` tool rows.
103103

104104
## Compact memory tool rendering
105105

106-
`gentle-engram` owns the Pi chrome for Engram memory tools by registering compact Pi-native `mem_*` tools in the companion package. When tools such as `mem_search`, `mem_context`, `mem_save`, `mem_session_summary`, `mem_get_observation`, `mem_judge`, and `mem_doctor` run in Pi, the default collapsed view stays compact:
106+
`gentle-engram` owns the Pi chrome for Engram memory tools by registering compact Pi-native `mem_*` tools in the companion package. When tools such as `mem_search`, `mem_context`, `mem_save`, `mem_session_summary`, `mem_get_observation`, `mem_review`, `mem_judge`, and `mem_doctor` run in Pi, the default collapsed view stays compact:
107107

108108
```text
109109
🧠 search “auth model” …
110110
↳ ✓ 4 results
111111
```
112112

113+
For lifecycle review, `mem_review` keeps the collapsed output explicit without exposing raw tool payloads:
114+
115+
```text
116+
🧠 review list “engram” limit 10 …
117+
↳ ✓ 3 need review
118+
119+
🧠 review mark_reviewed #42 …
120+
↳ ✓ reviewed #42
121+
```
122+
123+
`action=list` shows memories whose local `review_after` timestamp is due. `action=mark_reviewed` asks Engram core to reset that observation's local review clock according to its memory type. That review reset is local-only today: it updates the local lifecycle metadata but is not treated as a cloud/git sync mutation until the sync wire format carries lifecycle review fields.
124+
113125
Normal memory activity also updates the status bar with short progress/result text such as `🧠 engram · search…` and `🧠 engram · ✓ 4 results`. The extension does not use notifications for normal memory operations.
114126

115127
When a tool call fails because Engram cannot determine which project to use, the status bar shows an actionable label instead of the generic `error`:

plugin/pi/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const ENGRAM_TOOLS = [
3838
"mem_current_project",
3939
"mem_doctor",
4040
"mem_capture_passive",
41+
"mem_review",
4142
"mem_judge",
4243
"mem_compare",
4344
] as const;
@@ -488,6 +489,13 @@ const MEMORY_TOOL_SCHEMAS: Record<string, ReturnType<typeof Type.Object>> = {
488489
session_id: optionalString("Session ID to associate with"),
489490
source: optionalString("Source identifier, e.g. subagent-stop or session-end"),
490491
}),
492+
mem_review: Type.Object({
493+
action: Type.String({ description: "Action: list | mark_reviewed" }),
494+
project: optionalString("Optional project filter for action=list"),
495+
limit: optionalNumber("Max results for action=list"),
496+
observation_id: optionalNumber("Observation id for action=mark_reviewed"),
497+
id: optionalNumber("Alias for observation_id"),
498+
}),
491499
mem_judge: Type.Object({
492500
judgment_id: Type.String({ description: "The relation judgment_id returned by mem_save candidates" }),
493501
relation: Type.String({ description: "Verdict: related | compatible | scoped | conflicts_with | supersedes | not_conflict" }),
@@ -640,6 +648,19 @@ async function callMemoryTool(toolName: string, params: Record<string, unknown>,
640648
source: params.source || "pi-tool",
641649
},
642650
});
651+
case "mem_review": {
652+
const action = String(params.action || "").trim();
653+
if (action === "list") {
654+
return engramFetch(`/review${queryString({ project: params.project, limit: params.limit })}`);
655+
}
656+
if (action === "mark_reviewed") {
657+
return engramFetch("/review/mark_reviewed", {
658+
method: "POST",
659+
body: { observation_id: params.observation_id || params.id },
660+
});
661+
}
662+
throw new Error("action must be one of: list, mark_reviewed");
663+
}
643664
case "mem_judge":
644665
return engramFetch("/conflicts/judge", {
645666
method: "POST",

plugin/pi/memory-tool-chrome.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const TOOL_LABELS = {
1717
mem_capture_passive: "capture passive",
1818
mem_judge: "judge",
1919
mem_compare: "compare",
20+
mem_review: "review",
2021
};
2122

2223
const ARG_KEYS = {
@@ -38,6 +39,7 @@ const ARG_KEYS = {
3839
mem_capture_passive: ["source", "content"],
3940
mem_judge: ["judgment_id", "relation"],
4041
mem_compare: ["memory_id_a", "memory_id_b"],
42+
mem_review: ["action", "project", "limit", "observation_id", "id"],
4143
};
4244

4345
export const SUPPORTED_MEMORY_TOOLS = Object.freeze(Object.keys(TOOL_LABELS));
@@ -58,6 +60,8 @@ function quote(value) {
5860
}
5961

6062
export function compactToolArg(toolName, args = {}) {
63+
if (toolName === "mem_review") return compactReviewArg(args);
64+
6165
const keys = ARG_KEYS[toolName] ?? [];
6266
for (const key of keys) {
6367
const value = args?.[key];
@@ -68,6 +72,19 @@ export function compactToolArg(toolName, args = {}) {
6872
return "";
6973
}
7074

75+
function compactReviewArg(args = {}) {
76+
const parts = [];
77+
if (args.action !== undefined && args.action !== null && args.action !== "") parts.push(String(args.action));
78+
79+
const id = args.observation_id ?? args.id;
80+
if (id !== undefined && id !== null && id !== "") parts.push(`#${id}`);
81+
82+
if (args.project !== undefined && args.project !== null && args.project !== "") parts.push(quote(args.project));
83+
if (args.limit !== undefined && args.limit !== null && args.limit !== "") parts.push(`limit ${args.limit}`);
84+
85+
return parts.join(" ");
86+
}
87+
7188
function firstTextContent(result) {
7289
const block = result?.content?.find?.((entry) => entry?.type === "text" && typeof entry.text === "string");
7390
return block?.text ?? "";
@@ -83,6 +100,7 @@ function countItems(value) {
83100
if (Array.isArray(value?.observations)) return value.observations.length;
84101
if (Array.isArray(value?.sessions)) return value.sessions.length;
85102
if (Array.isArray(value?.prompts)) return value.prompts.length;
103+
if (typeof value?.count === "number") return value.count;
86104
return undefined;
87105
}
88106

@@ -112,6 +130,11 @@ export function compactResultStatus(toolName, result, options = {}) {
112130
if (toolName === "mem_capture_passive") return `✓ captured ${data?.saved ?? count ?? 0}`;
113131
if (toolName === "mem_judge") return data?.relation?.sync_id ? `✓ judged ${data.relation.sync_id}` : "✓ judged";
114132
if (toolName === "mem_compare") return data?.sync_id ? `✓ ${data.sync_id}` : "✓ compared";
133+
if (toolName === "mem_review") {
134+
if (count !== undefined) return `✓ ${count} need${count === 1 ? "s" : ""} review`;
135+
const id = data?.id ?? data?.observation_id ?? data?.observation?.id;
136+
return id ? `✓ reviewed #${id}` : "✓ reviewed";
137+
}
115138
return "✓ done";
116139
}
117140

plugin/pi/test/index-source.test.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,14 @@ test("ambiguous_project error maps to actionable status label, not generic 'erro
2727
// The bare '· error' hardcoded string should no longer be present in the catch block
2828
assert.doesNotMatch(source, /setStatus\?\.\("engram",\s*`🧠 \$\{project\} · error`\)/);
2929
});
30+
31+
test("mem_review is registered as a Pi-native executable memory tool", () => {
32+
assert.match(source, /const ENGRAM_TOOLS = \[[\s\S]*"mem_review"/);
33+
assert.match(source, /mem_review: Type\.Object\(\{[\s\S]*action: Type\.String\(\{ description: "Action: list \| mark_reviewed" \}\)/);
34+
assert.match(source, /mem_review: Type\.Object\(\{[\s\S]*observation_id: optionalNumber\("Observation id for action=mark_reviewed"\)/);
35+
assert.match(source, /mem_review: Type\.Object\(\{[\s\S]*id: optionalNumber\("Alias for observation_id"\)/);
36+
assert.match(source, /case "mem_review":[\s\S]*action === "list"[\s\S]*engramFetch\(`\/review\$\{queryString\(\{ project: params\.project, limit: params\.limit \}\)\}`\)/);
37+
assert.match(source, /case "mem_review":[\s\S]*action === "mark_reviewed"[\s\S]*engramFetch\("\/review\/mark_reviewed"/);
38+
assert.match(source, /case "mem_review":[\s\S]*body: \{ observation_id: params\.observation_id \|\| params\.id \}/);
39+
assert.match(source, /for \(const toolName of ENGRAM_TOOLS\)[\s\S]*executeMemoryTool\(toolName/);
40+
});

0 commit comments

Comments
 (0)