Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,21 @@ The optional project filter is enforced: an observation owned by another project

### mem_get_observation

Get full untruncated content of a specific observation by ID.
Get the content of a specific observation by ID.

With no extra parameters this returns the full untruncated body (unchanged). Optional partial-read parameters reduce token cost on large observations. Offsets and lengths are counted in **runes**, not bytes.

**Mechanical paging β€” `offset` + `limit`**

`mem_get_observation(id: 475, offset: 12000, limit: 2000)` returns runes `[offset, offset+limit)`. `limit` without `offset` starts at `0`. `offset` past the end returns an empty window (no error). Default `limit` is 2000.

Optional partial-read numeric values must be safe integers; negative values are rejected.

**Match-scoped read β€” `find` + `context`**

`mem_get_observation(id: 475, find: "test 7", context: 600)` returns merged windows of `context` runes on each side of every literal match, prefixed with each window's rune offset. Overlapping or adjacent contexts are merged; the header still reports literal occurrence count. `find` with no matches returns zero windows (no error). `context` without `find` is an error. Default `context` is 600.

`offset`/`limit` and `find`/`context` are mutually exclusive.

### mem_session_summary

Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Next session starts β†’ Previous session context is injected automatically
| `mem_session_summary` | Save end-of-session summary |
| `mem_context` | Get recent context from previous sessions |
| `mem_timeline` | Chronological context around a specific observation |
| `mem_get_observation` | Get full content of a specific memory |
| `mem_get_observation` | Get observation content by ID (full body, or optional rune-ranged / find windows) |
| `mem_save_prompt` | Save a user prompt for future context |
| `mem_stats` | Memory system statistics |
| `mem_session_start` | Register a session start |
Expand All @@ -85,7 +85,7 @@ Token-efficient memory retrieval β€” don't dump everything, drill in:
```
1. mem_search "auth middleware" β†’ compact results with IDs (~100 tokens each)
2. mem_timeline observation_id=42 β†’ what happened before/after in that session
3. mem_get_observation id=42 β†’ full untruncated content
3. mem_get_observation id=42 β†’ full body, or offset/limit / find/context windows
```

---
Expand Down
117 changes: 112 additions & 5 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -660,7 +662,7 @@ Examples:
if shouldRegister("mem_get_observation", allowlist) {
srv.AddTool(
mcp.NewTool("mem_get_observation",
mcp.WithDescription("Get the full content of a specific observation by ID. Use when you need the complete, untruncated content of an observation found via mem_search or mem_timeline."),
mcp.WithDescription("Get the content of a specific observation by ID. Omit partial-read params for the full untruncated body. Use offset/limit for a rune-ranged slice, or find/context for merged windows around literal occurrences. The find header reports occurrences, not window count. Optional numeric inputs must be safe integers; negative values are rejected. The two groups are mutually exclusive."),
mcp.WithTitleAnnotation("Get Observation"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
Expand All @@ -670,6 +672,18 @@ Examples:
mcp.Required(),
mcp.Description("The observation ID to retrieve"),
),
mcp.WithNumber("offset",
mcp.Description("Safe-integer rune offset for a ranged read. Negative values are rejected. Mutually exclusive with find/context. Omit with no other partial-read params to return the full body."),
),
mcp.WithNumber("limit",
mcp.Description("Safe-integer rune length for a ranged read (default 2000). Negative values are rejected. Limit without offset starts at offset 0. Mutually exclusive with find/context."),
),
mcp.WithString("find",
mcp.Description("Literal substring to locate inside the observation. Returns merged windows around overlapping or adjacent contexts; the header reports literal occurrences. Mutually exclusive with offset/limit."),
),
mcp.WithNumber("context",
mcp.Description("Safe-integer rune padding on each side of every find match (default 600). Negative values are rejected. Overlapping or adjacent contexts merge; the header reports literal occurrences. Requires find."),
),
),
handleGetObservation(s, cfg, activity),
)
Expand Down Expand Up @@ -1978,12 +1992,21 @@ func handleGetObservation(s *store.Store, cfg MCPConfig, activities ...*SessionA
if id == 0 {
return mcp.NewToolResultError("id is required"), nil
}
readReq, requestErr := observationReadRequest(req)
if requestErr != nil {
return mcp.NewToolResultError(requestErr.Error()), nil
}

obs, err := s.GetObservation(id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Observation #%d not found", id)), nil
}

read, readErr := store.ResolveObservationRead(obs.Content, readReq)
if readErr != nil {
return mcp.NewToolResultError(readErr.Error()), nil
}

// Resolve project from process override/cwd (REQ-310, REQ-314). No per-call
// override is possible for get-by-ID.
detRes, detErr := resolveReadProjectWithProcessOverride(s, "", cfg.DefaultProject)
Expand All @@ -2003,21 +2026,105 @@ func handleGetObservation(s *store.Store, cfg MCPConfig, activities ...*SessionA
}
duplicateMeta := fmt.Sprintf("\nDuplicates: %d", obs.DuplicateCount)
revisionMeta := fmt.Sprintf("\nRevisions: %d", obs.RevisionCount)

result := fmt.Sprintf("#%d [%s] %s\n%s\nSession: %s%s%s\nCreated: %s",
obs.ID, obs.Type, obs.Title,
obs.Content,
meta := fmt.Sprintf("Session: %s%s%s\nCreated: %s",
obs.SessionID, obsProject+scope+topic, toolName+duplicateMeta+revisionMeta,
timeutil.FormatLocal(obs.CreatedAt),
)

result := formatGetObservationResult(obs, read, meta)

if detErr != nil {
return readProjectErrorResult(activity, detRes, detErr), nil
}
return respondWithProject(detRes, result, nil), nil
}
}

func observationReadRequest(req mcp.CallToolRequest) (store.ObservationReadRequest, error) {
args := req.GetArguments()
out := store.ObservationReadRequest{}
var err error
if out.Offset, err = optionalSafeIntArgument(args, "offset"); err != nil {
return store.ObservationReadRequest{}, err
}
if out.Limit, err = optionalSafeIntArgument(args, "limit"); err != nil {
return store.ObservationReadRequest{}, err
}
if v, ok := args["find"].(string); ok {
out.Find = &v
}
if out.Context, err = optionalSafeIntArgument(args, "context"); err != nil {
return store.ObservationReadRequest{}, err
}
return out, nil
}

func optionalSafeIntArgument(args map[string]any, name string) (*int, error) {
raw, present := args[name]
if !present {
return nil, nil
}
v, ok := raw.(float64)
if !ok {
return nil, fmt.Errorf("%s must be a safe integer", name)
}
const maxSafeMCPInteger = (1 << 53) - 1
maxInt := int(^uint(0) >> 1)
minInt := -maxInt - 1
if math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v < -maxSafeMCPInteger || v > maxSafeMCPInteger || v < float64(minInt) || v > float64(maxInt) {
return nil, fmt.Errorf("%s must be a safe integer", name)
}
n := int(v)
return &n, nil
}

func formatGetObservationResult(obs *store.Observation, read store.ObservationReadResult, meta string) string {
if read.Mode == store.ObservationReadFull {
return fmt.Sprintf("#%d [%s] %s\n%s\n%s",
obs.ID, obs.Type, obs.Title, read.Content, meta)
}

header := fmt.Sprintf("#%d %q β€” %s", obs.ID, obs.Title, formatRuneCount(read.TotalRunes))
var b strings.Builder
if read.Mode == store.ObservationReadFind {
fmt.Fprintf(&b, "%s, %d matches for %q\n", header, read.MatchCount, read.Find)
for _, w := range read.Windows {
fmt.Fprintf(&b, "\n[offset %s]\n%s\n", formatIntWithComma(w.Offset), w.Content)
}
} else {
fmt.Fprintf(&b, "%s\n\n[offset %s, limit %s]\n%s\n",
header, formatIntWithComma(read.Offset), formatIntWithComma(read.Limit), read.Content)
}
b.WriteString("\n")
b.WriteString(meta)
return b.String()
}

func formatRuneCount(n int) string {
return formatIntWithComma(n) + " runes total"
}

func formatIntWithComma(n int) string {
if n < 0 {
return "-" + formatIntWithComma(-n)
}
s := strconv.Itoa(n)
if len(s) <= 3 {
return s
}
pre := len(s) % 3
if pre == 0 {
pre = 3
}
var b strings.Builder
b.WriteString(s[:pre])
for i := pre; i < len(s); i += 3 {
b.WriteByte(',')
b.WriteString(s[i : i+3])
}
return b.String()
}

// handleSessionSummary returns a tool handler function that saves a comprehensive
// end-of-session summary memory. It supports explicit project override matching
// the precedence of mem_save.
Expand Down
Loading