Compare commits

..
15 Commits
Author SHA1 Message Date
github-actions[bot] f19c247278 chore: update marketplace for v0.21.127 2026-10-12 03:32:13 +00:00
github-actions[bot]andGitHub 8cddc88ab6 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-12 03:25:04 +00:00
github-actions[bot] c9c0d988bc chore: update marketplace for v0.21.123 2026-10-11 03:22:28 +00:00
github-actions[bot]andGitHub 3945c187be chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-11 03:16:47 +00:00
github-actions[bot] 06b8b47584 chore: update marketplace for v0.21.119 2026-10-10 03:25:31 +00:00
github-actions[bot]andGitHub 8dc524ea59 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-10 03:17:27 +00:00
github-actions[bot] 9dc59d13fe chore: update marketplace for v0.21.115 2026-10-10 00:37:32 +00:00
lukaszraczyloandGitHub d8c49d8d89 fix(autoupdate): pass fts5 build tag to the dependency update (#76)
The scheduled Update dependencies run fails in the Build and test Go step.
The error is "no such module: fts5", because the tests run without the fts5 tag.
ci.yaml already passes build-tags fts5 to go-pr.yaml.

This change passes the same tag to renovate-autoupdate.yaml.
2026-10-10 01:32:15 +01:00
github-actions[bot] 6e26c4e85d chore: update marketplace for v0.21.111 2026-10-06 19:37:46 +00:00
8c2c69582a fix(lint): clear the two findings left by #69 and #70 (#72)
- observation_update_test.go (#69) lacked the fts5 build tag its helper
  (testObservationStore, in observation_store_test.go) has, so `go vet ./...`
  without tags failed with "undefined: testObservationStore".
- Client.knn (#70) concatenates its WHERE clause into the query. The clause is
  built only from constant text with "?" placeholders (see nearest) and every
  value goes through args, so gosec's G202 is a false positive; annotate it the
  way the file already annotates G201.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-06 20:32:14 +01:00
github-actions[bot] fbe938fef2 chore: update marketplace for v0.21.105 2026-10-04 22:25:54 +00:00
github-actions[bot] 64c2ace22e chore: update marketplace for v0.21.99 2026-10-04 22:20:54 +00:00
f2886bbedb fix(mcp): do not end an idle stdio server by closing stdin (#71)
After 30 minutes without a message the idle monitor closed stdin and expected the read loop to end. On a real, blocking stdin a read already in progress is not woken by closing the file, so the server lived on, the client's next message completed that read and the one after failed with 'read /dev/stdin: file already closed', which ended the connection mid-call.

The idle shutdown is now off by default (a stdio server lives as long as its client; orphans are still caught by the parent check). CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT (a Go duration) turns it on, and then it cancels the loop's context so Run returns nil instead of closing stdin.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:16:27 +01:00
9b6e5f7648 fix(vector): answer project-filtered queries with two searches (#70)
A project filter was '(project = ? OR scope = global)', which vec0 rejects in a nearest-neighbour query ('A LIMIT or k = ? constraint is required'). The only caller that passes a project is the de-duplication of new observations, which treats an error as 'insert', so semantic de-duplication never took effect.

Run one search for the project and one for global documents and merge them by distance without repeats, cut to the limit. QueryMultiField asks for enough field documents and keeps the best per observation; its two skipped tests are enabled. Because de-duplication now runs, canMergeInto skips a superseded or deleted note as a merge target.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:16:16 +01:00
055103cb3b fix(db): stop writing a column that does not exist when editing an observation (#69)
UpdateObservation set updated_at_epoch, but the observations table has no such column, so every PUT /api/observations/{id} failed with HTTP 500 (no such column: updated_at_epoch). Remove the assignment; nothing reads an edited-at time. An update that changes nothing now returns the observation, as intended. UpdateObservation had no tests: add store and handler tests.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:16:01 +01:00
15 changed files with 581 additions and 147 deletions
+1
View File
@@ -16,6 +16,7 @@ jobs:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
build-tags: "fts5"
node-working-directory: ui
require-node-tests: false
release-workflow: release.yaml
+4 -4
View File
@@ -16,7 +16,7 @@ require (
github.com/stretchr/testify v1.12.1
github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.36.0
golang.org/x/sync v0.23.0
golang.org/x/sync v0.24.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
)
@@ -25,7 +25,7 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
@@ -33,6 +33,6 @@ require (
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
)
+8 -8
View File
@@ -20,8 +20,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
@@ -47,12 +47,12 @@ github.com/yalue/onnxruntime_go v1.36.0 h1:iH1Q++DcsyT9sWtN26KYimESlI5hhXpKaChHD
github.com/yalue/onnxruntime_go v1.36.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
-3
View File
@@ -252,9 +252,6 @@ func (s *ObservationStore) UpdateObservation(ctx context.Context, id int64, upda
updates["scope"] = sql.NullString{String: *update.Scope, Valid: true}
}
// Add updated_at timestamp
updates["updated_at_epoch"] = sql.NullInt64{Int64: time.Now().Unix(), Valid: true}
if len(updates) == 0 {
// Nothing to update, just return existing observation
return toModelObservation(&dbObs), nil
@@ -0,0 +1,96 @@
//go:build fts5
package gorm
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
func storeForUpdate(t *testing.T) (*ObservationStore, int64, func()) {
t.Helper()
obsStore, store, cleanup := testObservationStore(t)
ctx := context.Background()
sessionID, err := NewSessionStore(store).CreateSDKSession(ctx, "claude-1", "test-project", "")
require.NoError(t, err)
id, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", &models.ParsedObservation{
Type: models.ObsTypeDiscovery, Title: "Original title", Subtitle: "Original subtitle", Narrative: "Original narrative",
Facts: []string{"fact one"}, Concepts: []string{"cache"}, FilesRead: []string{"a.go"},
}, int(sessionID), 1)
require.NoError(t, err)
return obsStore, id, cleanup
}
func ptr[T any](v T) *T { return &v }
func TestUpdateObservation_ChangesOnlyTheGivenFields(t *testing.T) {
obsStore, id, cleanup := storeForUpdate(t)
defer cleanup()
ctx := context.Background()
got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("New title"), Narrative: ptr("New narrative")})
require.NoError(t, err)
assert.Equal(t, "New title", got.Title.String)
assert.Equal(t, "New narrative", got.Narrative.String)
assert.Equal(t, "Original subtitle", got.Subtitle.String, "a field that was not given stays")
assert.Equal(t, []string{"fact one"}, []string(got.Facts))
stored, err := obsStore.GetObservationByID(ctx, id)
require.NoError(t, err)
assert.Equal(t, "New title", stored.Title.String, "and it is stored, not only returned")
}
func TestUpdateObservation_ReplacesListsAndScope(t *testing.T) {
obsStore, id, cleanup := storeForUpdate(t)
defer cleanup()
ctx := context.Background()
got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{
Facts: &[]string{"a", "b"}, Concepts: &[]string{}, FilesRead: &[]string{"x.go"}, FilesModified: &[]string{"y.go"}, Scope: ptr("global"),
})
require.NoError(t, err)
assert.Equal(t, []string{"a", "b"}, []string(got.Facts))
assert.Empty(t, got.Concepts, "an empty list clears the field")
assert.Equal(t, []string{"x.go"}, []string(got.FilesRead))
assert.Equal(t, []string{"y.go"}, []string(got.FilesModified))
assert.Equal(t, models.ScopeGlobal, got.Scope)
}
func TestUpdateObservation_NothingToChangeReturnsTheObservation(t *testing.T) {
obsStore, id, cleanup := storeForUpdate(t)
defer cleanup()
got, err := obsStore.UpdateObservation(context.Background(), id, &ObservationUpdate{})
require.NoError(t, err)
assert.Equal(t, "Original title", got.Title.String)
}
func TestUpdateObservation_Refusals(t *testing.T) {
obsStore, id, cleanup := storeForUpdate(t)
defer cleanup()
ctx := context.Background()
_, err := obsStore.UpdateObservation(ctx, id, nil)
assert.Error(t, err, "a nil update")
_, err = obsStore.UpdateObservation(ctx, 999999, &ObservationUpdate{Title: ptr("x")})
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestUpdateObservation_KeepsTheSearchIndexInStep(t *testing.T) {
obsStore, id, cleanup := storeForUpdate(t)
defer cleanup()
ctx := context.Background()
_, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("Zebrafish enclosure")})
require.NoError(t, err)
var n int64
require.NoError(t, obsStore.db.WithContext(ctx).Raw(
`SELECT COUNT(*) FROM observations_fts WHERE observations_fts MATCH 'zebrafish'`).Scan(&n).Error)
assert.EqualValues(t, 1, n, "the full-text index sees the new title")
}
+38
View File
@@ -0,0 +1,38 @@
package mcp
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestIdleTimeoutFromEnv(t *testing.T) {
// The idle shutdown is off unless asked for; anything that is not a positive duration leaves it off.
for value, want := range map[string]time.Duration{
"": 0,
"0": 0,
"abc": 0,
"-5m": 0,
"45": 0, // a bare number is not a duration
"90m": 90 * time.Minute,
"2h": 2 * time.Hour,
"30s": 30 * time.Second,
" 2h ": 0, // not trimmed: the operator wrote it, so it is wrong rather than guessed
} {
t.Run(value, func(t *testing.T) {
t.Setenv(IdleTimeoutEnv, value)
assert.Equal(t, want, idleTimeoutFromEnv())
})
}
}
func TestNewServer_HasNoIdleTimeoutByDefault(t *testing.T) {
t.Setenv(IdleTimeoutEnv, "")
assert.Zero(t, NewServer(nil, "http://localhost:1", "p", "v").idleTimeout)
}
func TestNewServer_TakesTheIdleTimeoutFromTheEnvironment(t *testing.T) {
t.Setenv(IdleTimeoutEnv, "3h")
assert.Equal(t, 3*time.Hour, NewServer(nil, "http://localhost:1", "p", "v").idleTimeout)
}
+97
View File
@@ -0,0 +1,97 @@
//go:build unix
package mcp
import (
"bytes"
"context"
"os"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// blockingPipe returns the two ends of a pipe in blocking mode, the way an inherited stdin is: a Read on it cannot be
// woken by closing the file (the standard pipe from os.Pipe is pollable and can, which hid this).
func blockingPipe(t *testing.T) (r, w *os.File) {
t.Helper()
var fds [2]int
require.NoError(t, syscall.Pipe(fds[:]))
r = os.NewFile(uintptr(fds[0]), "stdin-like")
w = os.NewFile(uintptr(fds[1]), "stdin-writer")
t.Cleanup(func() { _ = w.Close(); _ = r.Close() })
return r, w
}
type lockedBuffer struct {
buf bytes.Buffer
mu sync.Mutex
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// The idle timeout used to close stdin and hope the read loop ended. On a real (blocking) stdin the read stays blocked,
// so the server logged "shutting down" and then lived on with a closed stdin; the next message from the client woke the
// read, which failed with "file already closed" and killed the connection mid-call (seen in Claude Desktop after the
// connector sat unused for more than 30 minutes). An idle timeout that is on must end Run for real.
func TestRun_IdleTimeoutEndsTheServerCleanly(t *testing.T) {
t.Parallel()
stdin, _ := blockingPipe(t)
server := &Server{stdin: stdin, stdout: &lockedBuffer{}, idleTimeout: 50 * time.Millisecond, monitorInterval: 10 * time.Millisecond}
done := make(chan error, 1)
go func() { done <- server.Run(context.Background()) }()
select {
case err := <-done:
require.NoError(t, err, "an idle shutdown is a clean exit, not an error")
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after the idle timeout: stdin was closed but the blocked read never ended")
}
}
// By default there is no idle timeout: a stdio server lives as long as its client, and a client that talks to it after a
// long pause must get an answer.
func TestRun_WithoutAnIdleTimeoutTheServerKeepsAnsweringAfterALongPause(t *testing.T) {
t.Parallel()
stdin, stdinWriter := blockingPipe(t)
stdout := &lockedBuffer{}
server := &Server{stdin: stdin, stdout: stdout, monitorInterval: 5 * time.Millisecond}
done := make(chan error, 1)
go func() { done <- server.Run(context.Background()) }()
time.Sleep(300 * time.Millisecond) // many monitor ticks with no traffic
select {
case err := <-done:
t.Fatalf("Run ended during a pause with no traffic: %v", err)
default:
}
_, err := stdinWriter.WriteString(`{"jsonrpc":"2.0","id":7,"method":"tools/list"}` + "\n")
require.NoError(t, err)
assert.Eventually(t, func() bool { return bytes.Contains([]byte(stdout.String()), []byte(`"id":7`)) }, 3*time.Second, 10*time.Millisecond,
"the request after the pause is answered")
select {
case err := <-done:
t.Fatalf("Run ended after answering: %v", err)
default:
}
}
+41 -8
View File
@@ -30,6 +30,11 @@ type Server struct {
version string
writeMu sync.Mutex
lastActivity atomic.Int64
// idleTimeout ends the server after this long without a message; 0 (the default) never does. A stdio server lives
// as long as its client: ending it while the client is still connected makes the client's next call fail.
idleTimeout time.Duration
// monitorInterval is how often the parent and idle checks run (30s when 0).
monitorInterval time.Duration
}
// NewServer creates a new MCP server that proxies to the worker HTTP API.
@@ -41,9 +46,24 @@ func NewServer(client *http.Client, workerURL, project, version string) *Server
version: version,
stdin: os.Stdin,
stdout: os.Stdout,
idleTimeout: idleTimeoutFromEnv(),
}
}
// IdleTimeoutEnv names the environment variable that turns the idle shutdown on, as a Go duration such as "2h".
// It is off by default.
const IdleTimeoutEnv = "CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT"
// idleTimeoutFromEnv reads IdleTimeoutEnv; an unset, invalid or non-positive value means no idle shutdown.
func idleTimeoutFromEnv() time.Duration {
d, err := time.ParseDuration(os.Getenv(IdleTimeoutEnv))
if err != nil || d <= 0 {
return 0
}
return d
}
// Request represents a JSON-RPC request.
type Request struct {
JSONRPC string `json:"jsonrpc"`
@@ -82,6 +102,13 @@ type Tool struct {
// Run starts the MCP server loop.
func (s *Server) Run(ctx context.Context) error {
// An idle shutdown cancels this context. Closing stdin does not do it: on a real (blocking) stdin the read stays
// blocked, the server lived on with a closed stdin, and the client's next message woke the read with "file already
// closed", which killed the connection mid-call.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var idleStopped atomic.Bool
scanner := bufio.NewScanner(s.stdin)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 1MB max message size
@@ -100,11 +127,15 @@ func (s *Server) Run(ctx context.Context) error {
scanErr <- scanner.Err()
}()
// Monitor parent process liveness and idle timeout.
// If the parent dies (ppid changes) or no messages arrive for 30 minutes, shut down.
// Monitor parent process liveness and, when one is set, the idle timeout.
// If the parent dies (ppid changes) or no messages arrive for idleTimeout, shut down.
parentPID := os.Getppid()
go func() {
ticker := time.NewTicker(30 * time.Second)
interval := s.monitorInterval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
@@ -118,11 +149,10 @@ func (s *Server) Run(ctx context.Context) error {
}
return
}
if time.Since(time.Unix(s.lastActivity.Load(), 0)) > 30*time.Minute {
log.Info().Msg("MCP server idle timeout (30m), shutting down")
if closer, ok := s.stdin.(io.Closer); ok {
_ = closer.Close()
}
if s.idleTimeout > 0 && time.Since(time.Unix(s.lastActivity.Load(), 0)) > s.idleTimeout {
log.Info().Dur("idle", s.idleTimeout).Msg("MCP server idle timeout, shutting down")
idleStopped.Store(true)
cancel()
return
}
}
@@ -140,6 +170,9 @@ func (s *Server) Run(ctx context.Context) error {
case <-ctx.Done():
// Drain in-flight requests before returning.
wg.Wait()
if idleStopped.Load() {
return nil // an idle shutdown the operator asked for is a clean exit
}
return ctx.Err()
case line, ok := <-lines:
if !ok {
+88 -92
View File
@@ -5,6 +5,7 @@ import (
"context"
"database/sql"
"fmt"
"sort"
"strconv"
"strings"
"sync"
@@ -314,67 +315,13 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
}
defer c.readMu.RUnlock()
// Build query with filters
// vec0 supports WHERE clauses on metadata columns
args := []any{queryBlob}
sqlQuery := `
SELECT
doc_id,
distance,
sqlite_id,
doc_type,
field_type,
project,
scope
FROM vectors
WHERE embedding MATCH ?
`
// Add filters - these work with vec0 metadata columns
if docType, ok := where["doc_type"].(string); ok && docType != "" {
sqlQuery += " AND doc_type = ?"
args = append(args, docType)
}
if project, ok := where["project"].(string); ok && project != "" {
// Include project-specific OR global scope
sqlQuery += " AND (project = ? OR scope = 'global')"
args = append(args, project)
}
sqlQuery += " ORDER BY distance LIMIT ?"
args = append(args, limit)
rows, err := c.db.QueryContext(ctx, sqlQuery, args...)
// vec0 answers equality filters on metadata columns, but not an OR between them, so a project filter
// (the project's own documents, plus global ones) is asked as two searches and merged.
docType, _ := where["doc_type"].(string)
project, _ := where["project"].(string)
results, err := c.nearest(ctx, queryBlob, docType, project, limit)
if err != nil {
return nil, fmt.Errorf("query vectors: %w", err)
}
defer rows.Close()
var results []QueryResult
for rows.Next() {
var r QueryResult
var sqliteID int64
var docType, fieldType, project, scope sql.NullString
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
r.Similarity = DistanceToSimilarity(r.Distance)
r.Metadata = map[string]any{
"sqlite_id": float64(sqliteID), // Keep as float64 for compatibility
"doc_type": docType.String,
"field_type": fieldType.String,
"project": project.String,
"scope": scope.String,
}
results = append(results, r)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("iterate rows: %w", err)
return nil, err
}
// Cache the results
@@ -529,60 +476,109 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
}
defer c.readMu.RUnlock()
// Query with field type aggregation - get best match per document
sqlQuery := `
WITH ranked_results AS (
SELECT
doc_id,
distance,
sqlite_id,
doc_type,
field_type,
project,
scope,
ROW_NUMBER() OVER (PARTITION BY sqlite_id ORDER BY distance ASC) as rn
FROM vectors
WHERE embedding MATCH ?
AND doc_type = ?
AND (project = ? OR scope = 'global')
)
SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope
FROM ranked_results
WHERE rn = 1
ORDER BY distance
LIMIT ?
`
// Each observation has several vector documents (one per field). Ask for enough of them that `limit`
// distinct observations are likely to be among them, then keep the best match per observation.
found, err := c.nearest(ctx, queryBlob, docType, project, limit*multiFieldFetchFactor)
if err != nil {
return nil, err
}
results := make([]QueryResult, 0, limit)
seen := make(map[float64]bool, limit)
for _, r := range found { // best first
id, _ := r.Metadata["sqlite_id"].(float64)
if seen[id] {
continue
}
seen[id] = true
results = append(results, r)
if len(results) == limit {
break
}
}
return results, nil
}
rows, err := c.db.QueryContext(ctx, sqlQuery, queryBlob, docType, project, limit)
// multiFieldFetchFactor is how many vector documents QueryMultiField asks for per wanted observation.
const multiFieldFetchFactor = 6
// nearest runs the nearest-neighbour search for a document type and, when project is not empty, for that
// project's documents and the global ones. The caller holds the read lock. Results are best first, at most limit.
func (c *Client) nearest(ctx context.Context, queryBlob []byte, docType, project string, limit int) ([]QueryResult, error) {
base := " WHERE embedding MATCH ?"
baseArgs := []any{queryBlob}
if docType != "" {
base += " AND doc_type = ?"
baseArgs = append(baseArgs, docType)
}
if project == "" {
return c.knn(ctx, base, baseArgs, limit)
}
own, err := c.knn(ctx, base+" AND project = ?", append(append([]any{}, baseArgs...), project), limit)
if err != nil {
return nil, err
}
global, err := c.knn(ctx, base+" AND scope = 'global'", baseArgs, limit)
if err != nil {
return nil, err
}
return mergeNearest(limit, own, global), nil
}
// knn runs one vec0 nearest-neighbour query. where holds the MATCH and filters vec0 can answer on their own.
func (c *Client) knn(ctx context.Context, where string, args []any, limit int) ([]QueryResult, error) {
// #nosec G202 -- where is built from constant clauses with "?" placeholders only (see nearest); every value goes via args
rows, err := c.db.QueryContext(ctx,
"SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope FROM vectors"+where+" ORDER BY distance LIMIT ?",
append(append([]any{}, args...), limit)...)
if err != nil {
return nil, fmt.Errorf("query vectors: %w", err)
}
defer rows.Close()
// Pre-allocate with limit to avoid repeated slice growth
results := make([]QueryResult, 0, limit)
var results []QueryResult
for rows.Next() {
var r QueryResult
var sqliteID int64
var docTypeVal, fieldType, projectVal, scope sql.NullString
var docType, fieldType, project, scope sql.NullString
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docTypeVal, &fieldType, &projectVal, &scope); err != nil {
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
r.Similarity = DistanceToSimilarity(r.Distance)
r.Metadata = map[string]any{
"sqlite_id": float64(sqliteID),
"doc_type": docTypeVal.String,
"sqlite_id": float64(sqliteID), // Keep as float64 for compatibility
"doc_type": docType.String,
"field_type": fieldType.String,
"project": projectVal.String,
"project": project.String,
"scope": scope.String,
}
results = append(results, r)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("iterate rows: %w", err)
}
return results, nil
}
return results, rows.Err()
// mergeNearest combines result lists into one, best first, without repeating a document, at most limit long.
func mergeNearest(limit int, lists ...[]QueryResult) []QueryResult {
var all []QueryResult
seen := map[string]bool{}
for _, list := range lists {
for _, r := range list {
if !seen[r.ID] {
seen[r.ID] = true
all = append(all, r)
}
}
}
sort.SliceStable(all, func(i, j int) bool { return all[i].Distance < all[j].Distance })
if len(all) > limit {
all = all[:limit]
}
return all
}
// acquireRLockWithContext acquires a read lock on mu, respecting ctx cancellation.
+75 -5
View File
@@ -405,6 +405,50 @@ func TestClient_Query_WithProjectFilter(t *testing.T) {
results, err := client.Query(context.Background(), "authentication security", 10, nil)
require.NoError(t, err)
assert.NotEmpty(t, results, "Should find some results")
assert.Len(t, results, 3)
ids := func(rs []QueryResult) []string {
var out []string
for _, r := range rs {
out = append(out, r.ID)
}
return out
}
// With a project filter: the project's own documents and the global ones, not another project's.
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-a"))
require.NoError(t, err, "a project filter must be answerable (vec0 rejects an OR between metadata filters)")
assert.ElementsMatch(t, []string{"obs-1", "obs-3"}, ids(results), "project-a's note and the global one, not project-b's own")
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-b"))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"obs-2", "obs-3"}, ids(results))
// Best first, and the limit applies to the merged list.
results, err = client.Query(context.Background(), "authentication security", 1, BuildWhereFilter(DocTypeObservation, "project-a"))
require.NoError(t, err)
require.Len(t, results, 1)
all, err := client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-a"))
require.NoError(t, err)
assert.Equal(t, all[0].ID, results[0].ID, "the one result is the best of the merged list")
assert.LessOrEqual(t, all[0].Distance, all[1].Distance)
// A project nobody wrote to still sees the global documents.
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-z"))
require.NoError(t, err)
assert.Equal(t, []string{"obs-3"}, ids(results))
}
func TestMergeNearest(t *testing.T) {
r := func(id string, d float64) QueryResult { return QueryResult{ID: id, Distance: d} }
got := mergeNearest(3, []QueryResult{r("a", 0.5), r("b", 0.9)}, []QueryResult{r("b", 0.9), r("c", 0.1), r("d", 0.7)})
var ids []string
for _, x := range got {
ids = append(ids, x.ID)
}
assert.Equal(t, []string{"c", "a", "d"}, ids, "best first, a document found twice counts once, cut to the limit")
assert.Empty(t, mergeNearest(5))
assert.Len(t, mergeNearest(5, []QueryResult{r("a", 1)}, nil), 1)
}
func TestClient_IsConnected(t *testing.T) {
@@ -1105,8 +1149,6 @@ func TestClient_QueryBatch_WithContextCancellation(t *testing.T) {
// =============================================================================
func TestClient_QueryMultiField_Basic(t *testing.T) {
t.Skip("QueryMultiField SQL query needs 'k' parameter fix for sqlite-vec")
db, dbCleanup := testDB(t)
defer dbCleanup()
@@ -1173,8 +1215,6 @@ func TestClient_QueryMultiField_Basic(t *testing.T) {
}
func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
t.Skip("QueryMultiField SQL query needs 'k' parameter fix for sqlite-vec")
db, dbCleanup := testDB(t)
defer dbCleanup()
@@ -1217,7 +1257,37 @@ func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
require.NoError(t, err)
// Should include both project-scoped (matching project) and global
assert.NotEmpty(t, results)
require.Len(t, results, 2)
assert.ElementsMatch(t, []float64{1, 2}, []float64{
results[0].Metadata["sqlite_id"].(float64), results[1].Metadata["sqlite_id"].(float64),
})
}
// The query the de-duplication of new observations makes: the same project, a few results, and a note that
// says nearly the same thing must come back with a high similarity.
func TestClient_Query_ProjectFilterFindsANearDuplicate(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
text := "The shipping rate cache lives for sixty minutes and is stored in Redis."
require.NoError(t, client.AddDocuments(context.Background(), []Document{
{ID: "obs-1", Content: text, Metadata: map[string]any{"sqlite_id": int64(1), "doc_type": "observation", "project": "shop", "scope": "project"}},
{ID: "obs-2", Content: text, Metadata: map[string]any{"sqlite_id": int64(2), "doc_type": "observation", "project": "elsewhere", "scope": "project"}},
{ID: "obs-3", Content: "Parcel labels are printed as PDF.", Metadata: map[string]any{"sqlite_id": int64(3), "doc_type": "observation", "project": "shop", "scope": "project"}},
}))
results, err := client.Query(context.Background(), text, 3, BuildWhereFilter(DocTypeObservation, "shop"))
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, "obs-1", results[0].ID, "the same text in the same project is the best match")
assert.GreaterOrEqual(t, results[0].Similarity, 0.99)
for _, r := range results {
assert.NotEqual(t, "obs-2", r.ID, "another project's note is not a duplicate")
}
}
// =============================================================================
+59
View File
@@ -0,0 +1,59 @@
package worker
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func doUpdateRequest(t *testing.T, svc *Service, method, target string, body any) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
require.NoError(t, json.NewEncoder(&buf).Encode(body))
}
rec := httptest.NewRecorder()
svc.router.ServeHTTP(rec, httptest.NewRequest(method, target, &buf))
return rec
}
func TestHandleUpdateObservation_EditsAndReturnsTheObservation(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
id := createTestObservation(t, svc.observationStore, "proj_aaaaaa", "Old title", "Old narrative", []string{"cache"})
rec := doUpdateRequest(t, svc, http.MethodPut, fmt.Sprintf("/api/observations/%d", id),
map[string]any{"title": "New title", "facts": []string{"one", "two"}})
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp struct {
Observation struct {
Title string `json:"title"`
Narrative string `json:"narrative"`
Facts []string `json:"facts"`
} `json:"observation"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "New title", resp.Observation.Title)
assert.Equal(t, "Old narrative", resp.Observation.Narrative)
assert.Equal(t, []string{"one", "two"}, resp.Observation.Facts)
rec = doUpdateRequest(t, svc, http.MethodGet, fmt.Sprintf("/api/observations/%d", id), nil)
require.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "New title", "the edit is stored")
}
func TestHandleUpdateObservation_Refusals(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
id := createTestObservation(t, svc.observationStore, "proj_aaaaaa", "A title", "A narrative", nil)
assert.Equal(t, http.StatusNotFound, doUpdateRequest(t, svc, http.MethodPut, "/api/observations/999999", map[string]any{"title": "x"}).Code)
assert.Equal(t, http.StatusBadRequest, doUpdateRequest(t, svc, http.MethodPut, fmt.Sprintf("/api/observations/%d", id), map[string]any{"scope": "galaxy"}).Code)
assert.Equal(t, http.StatusBadRequest, doUpdateRequest(t, svc, http.MethodPut, "/api/observations/abc", map[string]any{"title": "x"}).Code)
}
+15 -1
View File
@@ -51,7 +51,7 @@ func (p *Processor) checkVectorDeduplication(ctx context.Context, obs *models.Pa
for _, r := range results {
if r.Similarity >= cfg.DeduplicationThreshold {
obsID := extractObservationIDFromVectorDoc(r)
if obsID > 0 {
if obsID > 0 && p.canMergeInto(ctx, obsID) {
return &DeduplicationResult{
ExistingID: obsID,
Similarity: r.Similarity,
@@ -64,6 +64,20 @@ func (p *Processor) checkVectorDeduplication(ctx context.Context, obs *models.Pa
return &DeduplicationResult{Action: "insert"}
}
// canMergeInto says whether a new observation may be merged into the existing one. A note a person superseded
// is hidden from sessions, so what was merged into it would be hidden as well: such a note is not a target,
// and neither is one that is gone.
func (p *Processor) canMergeInto(ctx context.Context, id int64) bool {
if p.observationStore == nil {
return true
}
existing, err := p.observationStore.GetObservationByID(ctx, id)
if err != nil {
return false
}
return existing != nil && !existing.IsSuperseded
}
// buildObservationSearchText creates searchable text from a parsed observation.
func buildObservationSearchText(obs *models.ParsedObservation) string {
var parts []string
+33
View File
@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
@@ -142,3 +143,35 @@ func TestCheckVectorDeduplication_EmptySearchText(t *testing.T) {
t.Errorf("expected Action='insert' for empty observation, got %q", result.Action)
}
}
func TestCanMergeInto(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := gorm.NewStore(gorm.Config{Path: dir + "/test.db", MaxConns: 1})
if err != nil {
t.Fatal(err)
}
defer store.Close()
obsStore := gorm.NewObservationStore(store, nil, nil, nil)
id, _, err := obsStore.StoreObservation(ctx, "s1", "proj", &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "A note"}, 1, 1)
if err != nil {
t.Fatal(err)
}
p := &Processor{observationStore: obsStore}
if !p.canMergeInto(ctx, id) {
t.Error("a live observation is a merge target")
}
if p.canMergeInto(ctx, id+1000) {
t.Error("an observation that does not exist is not")
}
if err := store.DB.Exec(`UPDATE observations SET is_superseded = 1 WHERE id = ?`, id).Error; err != nil {
t.Fatal(err)
}
if p.canMergeInto(ctx, id) {
t.Error("a superseded observation is hidden from sessions, so nothing is merged into it")
}
if !(&Processor{}).canMergeInto(ctx, id) {
t.Error("without an observation store there is nothing to check against")
}
}
+10 -10
View File
@@ -13,7 +13,7 @@
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
"version": "0.21.95",
"version": "0.21.127",
"author": {
"name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": {
"latest": "0.21.95",
"latest": "0.21.127",
"versions": {
"0.21.95": {
"releaseDate": "2026-10-04",
"0.21.127": {
"releaseDate": "2026-10-12",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.127/claude-mnemonic_0.21.127_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "3224be650ead03a23078f7033909ecaedf89c88d665b3aa83c70b2eb952722b9"
"sha256": "4191ab626fb31a046f6eaeb0f3e5febdbb50285bd0d6d4e21a69e626fe5bbda0"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.127/claude-mnemonic_0.21.127_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "4af6927b7857b03143e6eb817da7f44c39dd2b5674a7d1b7f505db7c3dd25b2f"
"sha256": "9245e63dc10ee88bae33d1519d8c31951ab0abeef045cfd940903bfcf5db3272"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.127/claude-mnemonic_0.21.127_windows_amd64.zip",
"format": "zip",
"sha256": "d779920957eea6b21a344556b5a36ec2a1a6c596e095575e73110c5cb997fc1a"
"sha256": "ced294d3791c4ebd0bfe37375589d1eff091a75f488eadf0b3b019bc87e3ea1d"
}
}
}
+16 -16
View File
@@ -802,9 +802,9 @@
"peer": true
},
"node_modules/@types/node": {
"version": "26.6.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1430,9 +1430,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"funding": [
{
"type": "opencollective",
@@ -1449,9 +1449,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"nanoid": "^3.3.19",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -1492,9 +1492,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -1624,16 +1624,16 @@
}
},
"node_modules/vite": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
"integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.11",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {