Skip to content

fix(api): show user-scope memories in /api/memories listing#124

Merged
tickernelz merged 1 commit into
tickernelz:mainfrom
leiverkus:feat/agent-tool-api
Jun 7, 2026
Merged

fix(api): show user-scope memories in /api/memories listing#124
tickernelz merged 1 commit into
tickernelz:mainfrom
leiverkus:feat/agent-tool-api

Conversation

@leiverkus
Copy link
Copy Markdown
Contributor

Summary

handleListMemories without a tag filter only walks project-scope shards and discards any row whose container_tag doesn't include the literal substring _project_. User-scope memories — canonical tag format opencode_user_<sha16> — are therefore structurally invisible from this endpoint, even though they store and search correctly.

The result is a confusing UX:

Endpoint Sees user-scope memories?
POST /api/memories (writing them)
GET /api/search
GET /api/stats (byScope.user and byType)
GET /api/memories (listing)
Web UI navigation (built on /api/memories)

Reproduction (against current main, v2.15.0)

# Write a user-scope memory
curl -sS -X POST http://127.0.0.1:4747/api/memories \
  -H "Content-Type: application/json" \
  -d '{"content": "test fact", "containerTag": "opencode_user_64feb0e31e5b9625", "type": "fact"}'
# → { "success": true, "data": { "id": "mem_..." } }

# Stats knows it's there
curl -sS http://127.0.0.1:4747/api/stats
# → { "byScope": { "user": 1, "project": 0 }, "byType": { "fact": 1 } }

# Search finds it
curl -sS "http://127.0.0.1:4747/api/search?q=test"
# → { "data": { "items": [ { "id": "mem_...", "content": "test fact" } ] } }

# Listing silently hides it
curl -sS "http://127.0.0.1:4747/api/memories"
# → { "data": { "items": [], "total": 0 } }     ← BUG

Root cause

src/services/api-handlers.ts:161-167:

} else {
  const shards = shardManager.getAllShards("project", "");      // ← only project shards
  for (const shard of shards) {
    const db = connectionManager.getConnection(shard.dbPath);
    const memories = vectorSearch.getAllMemories(db);
    allMemories.push(...memories.filter(
      (m: any) => m.container_tag?.includes(`_project_`)         // ← only project tags
    ));
  }
}

Two parallel filters: shard scope is hardcoded "project", and the row filter only keeps _project_ tags. Both have to be widened.

handleStats at line 742-743 was already correct (counts both _user_ and _project_), so this PR just brings the listing endpoint into agreement with what stats reports.

Fix

Iterate both project- and user-scope shards in the no-tag path, widen the defense-in-depth filter to match both canonical scope markers. The tagged path (when tag is provided) is unchanged because extractScopeFromTag already resolves the requested scope correctly.

Out of scope

handleListTags (line 105) is intentionally project-only by its return-shape contract ({ project: TagInfo[] }). Exposing user-scope tags there would be a contract-breaking change to the response shape, which doesn't fit a small UX fix. Happy to do that as a follow-up if you want — could either add a user key to the response or split into /api/tags/project + /api/tags/user.

Verification

  • bun test: 143 pass / 0 fail (no regressions)
  • bun run typecheck: clean
  • bun run build: clean
  • npx prettier --check: clean
  • Manually reproduced the bug on v2.15.0, applied this patch, confirmed all four endpoints now agree about what exists.

Environment

`handleListMemories` without a tag filter only walked project-scope shards
and discarded any row whose `container_tag` didn't include the literal
substring `_project_`. User-scope memories (canonical tag format
`opencode_user_<sha16>`) were therefore structurally invisible:

- The /api/memories endpoint returned `items: []` even when user-scope
  memories existed in the store
- `/api/stats` already counted both scopes correctly (see lines 742-743),
  so the listing endpoint disagreed with the stats endpoint about what
  exists
- The Web UI navigation, which is built on /api/memories, had no way to
  surface user-scope memories at all

Reproduction (current main, before this patch):

```
POST /api/memories  body: { "content": "...", "containerTag": "opencode_user_xxx" }
   → { success: true, data: { id: "mem_..." } }
GET /api/stats
   → { byScope: { user: 1, project: 0 }, byType: { fact: 1 } }
GET /api/memories                      ← BUG: hides the row above
   → { items: [], total: 0 }
GET /api/search?q=...                  ← finds it (unaffected)
   → { items: [{ id, content, ... }] }
```

Fix: iterate both project- and user-scope shards in the no-tag path and
widen the defense-in-depth filter to match both canonical scope markers
(`_project_` or `_user_`). Behavior with a tag filter is unchanged
because `extractScopeFromTag` still resolves the requested scope.

Verification:
- `bun test`: 143 pass / 0 fail (no existing tests regressed)
- `bun run typecheck`: clean
- `bun run build`: clean
- `npx prettier --check`: clean
- Manually reproduced the bug on v2.15.0 and confirmed this patch resolves
  it: user-scope memories now appear in /api/memories the same way they
  appear in /api/stats and /api/search.

Note: `handleListTags` (line 105) is intentionally project-only by its
return-shape contract (`{ project: TagInfo[] }`) — that's a separate
design decision, not touched here. If user-scope tag listing is wanted, it
should be a new endpoint or a contract-breaking response-shape change,
neither of which fits this small fix.
@tickernelz tickernelz merged commit 7edfad7 into tickernelz:main Jun 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants