Compare commits

...
7 Commits
Author SHA1 Message Date
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
67de0f0c84 fix(vector): re-sync a note's vectors by replacing them; roll back a failed insert (#68)
The vectors table is a vec0 virtual table, where INSERT OR REPLACE does not replace, so syncing a note that already had vectors failed with a UNIQUE constraint error and the edit never reached the index. AddDocuments also shadowed err inside its loop, so a failed insert left the write transaction open and later writes failed with 'database is locked'.

Add Client.ReplaceDocuments (delete and insert in one transaction) and use it from SyncObservation and SyncSummary. The shared write function has a named result, so every failed return rolls back. Fixes #66.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:15:46 +01:00
github-actions[bot] eba3136fff chore: update marketplace for v0.21.95 2026-10-04 10:23:27 +00:00
github-actions[bot]andGitHub 17f7bffef5 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 10:18:01 +00:00
github-actions[bot] 0d75b867e6 chore: update marketplace for v0.21.91 2026-10-04 09:21:00 +00:00
14 changed files with 788 additions and 225 deletions
-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} 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 { if len(updates) == 0 {
// Nothing to update, just return existing observation // Nothing to update, just return existing observation
return toModelObservation(&dbObs), nil return toModelObservation(&dbObs), nil
@@ -0,0 +1,94 @@
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 version string
writeMu sync.Mutex writeMu sync.Mutex
lastActivity atomic.Int64 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. // 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, version: version,
stdin: os.Stdin, stdin: os.Stdin,
stdout: os.Stdout, 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. // Request represents a JSON-RPC request.
type Request struct { type Request struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
@@ -82,6 +102,13 @@ type Tool struct {
// Run starts the MCP server loop. // Run starts the MCP server loop.
func (s *Server) Run(ctx context.Context) error { 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) scanner := bufio.NewScanner(s.stdin)
buf := make([]byte, 0, 64*1024) buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 1MB max message size scanner.Buffer(buf, 1024*1024) // 1MB max message size
@@ -100,11 +127,15 @@ func (s *Server) Run(ctx context.Context) error {
scanErr <- scanner.Err() scanErr <- scanner.Err()
}() }()
// Monitor parent process liveness and idle timeout. // Monitor parent process liveness and, when one is set, the idle timeout.
// If the parent dies (ppid changes) or no messages arrive for 30 minutes, shut down. // If the parent dies (ppid changes) or no messages arrive for idleTimeout, shut down.
parentPID := os.Getppid() parentPID := os.Getppid()
go func() { 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() defer ticker.Stop()
for { for {
select { select {
@@ -118,11 +149,10 @@ func (s *Server) Run(ctx context.Context) error {
} }
return return
} }
if time.Since(time.Unix(s.lastActivity.Load(), 0)) > 30*time.Minute { if s.idleTimeout > 0 && time.Since(time.Unix(s.lastActivity.Load(), 0)) > s.idleTimeout {
log.Info().Msg("MCP server idle timeout (30m), shutting down") log.Info().Dur("idle", s.idleTimeout).Msg("MCP server idle timeout, shutting down")
if closer, ok := s.stdin.(io.Closer); ok { idleStopped.Store(true)
_ = closer.Close() cancel()
}
return return
} }
} }
@@ -140,6 +170,9 @@ func (s *Server) Run(ctx context.Context) error {
case <-ctx.Done(): case <-ctx.Done():
// Drain in-flight requests before returning. // Drain in-flight requests before returning.
wg.Wait() wg.Wait()
if idleStopped.Load() {
return nil // an idle shutdown the operator asked for is a clean exit
}
return ctx.Err() return ctx.Err()
case line, ok := <-lines: case line, ok := <-lines:
if !ok { if !ok {
+124 -107
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -139,9 +140,22 @@ func NewClient(cfg Config, embedSvc *embedding.Service) (*Client, error) {
return c, nil return c, nil
} }
// AddDocuments adds documents with their embeddings to the vector store. // AddDocuments embeds the documents and inserts them. A document whose ID already exists is an error (the vec0
// table does not replace on insert); use ReplaceDocuments to write the documents of a note that may have some.
func (c *Client) AddDocuments(ctx context.Context, docs []Document) error { func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
if len(docs) == 0 { return c.writeDocuments(ctx, docs, "", 0)
}
// ReplaceDocuments writes the documents of one note (observation, summary, ...) in place of the ones it had: the old
// documents are deleted and the new ones inserted in one transaction, so an edit that removes a fact leaves no stale
// document behind and a failure leaves the old ones untouched. docs may be empty, which only deletes.
func (c *Client) ReplaceDocuments(ctx context.Context, docType string, sqliteID int64, docs []Document) error {
return c.writeDocuments(ctx, docs, docType, sqliteID)
}
// writeDocuments inserts docs; when docType is not empty it first deletes every document of that note.
func (c *Client) writeDocuments(ctx context.Context, docs []Document, docType string, sqliteID int64) (err error) {
if len(docs) == 0 && docType == "" {
return nil return nil
} }
@@ -154,9 +168,11 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
// Compute embeddings OUTSIDE the write lock for better concurrency. // Compute embeddings OUTSIDE the write lock for better concurrency.
// Embedding is ONNX inference (slow, mutex-protected internally) — holding // Embedding is ONNX inference (slow, mutex-protected internally) — holding
// writeMu during inference blocks all concurrent writes and reads. // writeMu during inference blocks all concurrent writes and reads.
embeddings, err := c.embedSvc.EmbedBatchWithContext(ctx, texts) var embeddings [][]float32
if err != nil { if len(texts) > 0 {
return fmt.Errorf("generate embeddings: %w", err) if embeddings, err = c.embedSvc.EmbedBatchWithContext(ctx, texts); err != nil {
return fmt.Errorf("generate embeddings: %w", err)
}
} }
// Acquire write lock for DB operations only // Acquire write lock for DB operations only
@@ -165,7 +181,7 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
// Insert into vectors table with model version tracking // Insert into vectors table with model version tracking
const insertQuery = ` const insertQuery = `
INSERT OR REPLACE INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version) INSERT INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
` `
@@ -176,6 +192,8 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
if err != nil { if err != nil {
return fmt.Errorf("begin transaction: %w", err) return fmt.Errorf("begin transaction: %w", err)
} }
// err is the named result, so every failed return below reaches this and the write lock is released. (A shadowed
// err in the loop once left the transaction open, and the database locked, after a failed insert.)
defer func() { defer func() {
if err != nil { if err != nil {
if rbErr := tx.Rollback(); rbErr != nil { if rbErr := tx.Rollback(); rbErr != nil {
@@ -185,6 +203,12 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
} }
}() }()
if docType != "" {
if _, err = tx.ExecContext(ctx, `DELETE FROM vectors WHERE doc_type = ? AND sqlite_id = ?`, docType, sqliteID); err != nil {
return fmt.Errorf("delete old documents: %w", err)
}
}
stmt, err := tx.PrepareContext(ctx, insertQuery) stmt, err := tx.PrepareContext(ctx, insertQuery)
if err != nil { if err != nil {
return fmt.Errorf("prepare statement: %w", err) return fmt.Errorf("prepare statement: %w", err)
@@ -193,29 +217,28 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
for i, doc := range docs { for i, doc := range docs {
// Serialize embedding to blob format // Serialize embedding to blob format
embBlob, err := sqlite_vec.SerializeFloat32(embeddings[i]) var embBlob []byte
if err != nil { if embBlob, err = sqlite_vec.SerializeFloat32(embeddings[i]); err != nil {
return fmt.Errorf("serialize embedding for %s: %w", doc.ID, err) return fmt.Errorf("serialize embedding for %s: %w", doc.ID, err)
} }
// Extract metadata // Extract metadata
sqliteID, _ := doc.Metadata["sqlite_id"].(int64) docSQLiteID, _ := doc.Metadata["sqlite_id"].(int64)
docType, _ := doc.Metadata["doc_type"].(string) docDocType, _ := doc.Metadata["doc_type"].(string)
fieldType, _ := doc.Metadata["field_type"].(string) fieldType, _ := doc.Metadata["field_type"].(string)
project, _ := doc.Metadata["project"].(string) project, _ := doc.Metadata["project"].(string)
scope, _ := doc.Metadata["scope"].(string) scope, _ := doc.Metadata["scope"].(string)
_, err = stmt.ExecContext(ctx, if _, err = stmt.ExecContext(ctx,
doc.ID, doc.ID,
embBlob, embBlob,
sqliteID, docSQLiteID,
docType, docDocType,
fieldType, fieldType,
project, project,
scope, scope,
modelVersion, modelVersion,
) ); err != nil {
if err != nil {
return fmt.Errorf("insert document %s: %w", doc.ID, err) return fmt.Errorf("insert document %s: %w", doc.ID, err)
} }
} }
@@ -292,67 +315,13 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
} }
defer c.readMu.RUnlock() defer c.readMu.RUnlock()
// Build query with filters // vec0 answers equality filters on metadata columns, but not an OR between them, so a project filter
// vec0 supports WHERE clauses on metadata columns // (the project's own documents, plus global ones) is asked as two searches and merged.
args := []any{queryBlob} docType, _ := where["doc_type"].(string)
project, _ := where["project"].(string)
sqlQuery := ` results, err := c.nearest(ctx, queryBlob, docType, project, limit)
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...)
if err != nil { if err != nil {
return nil, fmt.Errorf("query vectors: %w", err) return nil, 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)
} }
// Cache the results // Cache the results
@@ -507,60 +476,108 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
} }
defer c.readMu.RUnlock() defer c.readMu.RUnlock()
// Query with field type aggregation - get best match per document // Each observation has several vector documents (one per field). Ask for enough of them that `limit`
sqlQuery := ` // distinct observations are likely to be among them, then keep the best match per observation.
WITH ranked_results AS ( found, err := c.nearest(ctx, queryBlob, docType, project, limit*multiFieldFetchFactor)
SELECT if err != nil {
doc_id, return nil, err
distance, }
sqlite_id, results := make([]QueryResult, 0, limit)
doc_type, seen := make(map[float64]bool, limit)
field_type, for _, r := range found { // best first
project, id, _ := r.Metadata["sqlite_id"].(float64)
scope, if seen[id] {
ROW_NUMBER() OVER (PARTITION BY sqlite_id ORDER BY distance ASC) as rn continue
FROM vectors }
WHERE embedding MATCH ? seen[id] = true
AND doc_type = ? results = append(results, r)
AND (project = ? OR scope = 'global') if len(results) == limit {
) break
SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope }
FROM ranked_results }
WHERE rn = 1 return results, nil
ORDER BY distance }
LIMIT ?
`
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) {
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 { if err != nil {
return nil, fmt.Errorf("query vectors: %w", err) return nil, fmt.Errorf("query vectors: %w", err)
} }
defer rows.Close() defer rows.Close()
// Pre-allocate with limit to avoid repeated slice growth var results []QueryResult
results := make([]QueryResult, 0, limit)
for rows.Next() { for rows.Next() {
var r QueryResult var r QueryResult
var sqliteID int64 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) return nil, fmt.Errorf("scan row: %w", err)
} }
r.Similarity = DistanceToSimilarity(r.Distance) r.Similarity = DistanceToSimilarity(r.Distance)
r.Metadata = map[string]any{ r.Metadata = map[string]any{
"sqlite_id": float64(sqliteID), "sqlite_id": float64(sqliteID), // Keep as float64 for compatibility
"doc_type": docTypeVal.String, "doc_type": docType.String,
"field_type": fieldType.String, "field_type": fieldType.String,
"project": projectVal.String, "project": project.String,
"scope": scope.String, "scope": scope.String,
} }
results = append(results, r) 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. // acquireRLockWithContext acquires a read lock on mu, respecting ctx cancellation.
+139 -5
View File
@@ -405,6 +405,50 @@ func TestClient_Query_WithProjectFilter(t *testing.T) {
results, err := client.Query(context.Background(), "authentication security", 10, nil) results, err := client.Query(context.Background(), "authentication security", 10, nil)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, results, "Should find some results") 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) { func TestClient_IsConnected(t *testing.T) {
@@ -1105,8 +1149,6 @@ func TestClient_QueryBatch_WithContextCancellation(t *testing.T) {
// ============================================================================= // =============================================================================
func TestClient_QueryMultiField_Basic(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) db, dbCleanup := testDB(t)
defer dbCleanup() defer dbCleanup()
@@ -1173,8 +1215,6 @@ func TestClient_QueryMultiField_Basic(t *testing.T) {
} }
func TestClient_QueryMultiField_WithGlobalScope(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) db, dbCleanup := testDB(t)
defer dbCleanup() defer dbCleanup()
@@ -1217,7 +1257,37 @@ func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Should include both project-scoped (matching project) and global // 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")
}
} }
// ============================================================================= // =============================================================================
@@ -2152,3 +2222,67 @@ func TestGetOrComputeEmbedding_ContextCancelDuringSingleflight(t *testing.T) {
// Wait for first call to finish // Wait for first call to finish
<-firstDone <-firstDone
} }
func TestClient_AddDocuments_FailedBatchIsRolledBackAndDoesNotLockTheDatabase(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)
doc := func(id string, sqliteID int64) Document {
return Document{ID: id, Content: "text of " + id, Metadata: map[string]any{
"sqlite_id": sqliteID, "doc_type": "observation", "field_type": "narrative", "project": "alpha", "scope": "project",
}}
}
require.NoError(t, client.AddDocuments(context.Background(), []Document{doc("obs_1_narrative", 1)}))
// The second document of the batch exists already, so the batch fails after the first was inserted.
err = client.AddDocuments(context.Background(), []Document{doc("obs_2_narrative", 2), doc("obs_1_narrative", 1)})
require.Error(t, err)
var n int
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM vectors WHERE doc_id = 'obs_2_narrative'").Scan(&n))
assert.Zero(t, n, "nothing of the failed batch is kept")
// With the transaction left open, this would wait for the lock until the context ends.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, client.AddDocuments(ctx, []Document{doc("obs_3_narrative", 3)}), "a write after a failed one goes through")
}
func TestClient_ReplaceDocuments_ReplacesOnlyTheDocumentsOfThatNote(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)
ctx := context.Background()
doc := func(id string, sqliteID int64, docType, scope string) Document {
return Document{ID: id, Content: "text of " + id, Metadata: map[string]any{
"sqlite_id": sqliteID, "doc_type": docType, "field_type": "narrative", "project": "alpha", "scope": scope,
}}
}
require.NoError(t, client.AddDocuments(ctx, []Document{
doc("obs_1_narrative", 1, "observation", "global"), doc("obs_1_fact_0", 1, "observation", "global"),
doc("obs_2_narrative", 2, "observation", "global"),
doc("summary_1_request", 1, "session_summary", ""), // the same sqlite id, another kind of document
}))
require.NoError(t, client.ReplaceDocuments(ctx, "observation", 1, []Document{doc("obs_1_narrative", 1, "observation", "project")}))
count := func(where string, args ...any) (n int) {
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM vectors WHERE "+where, args...).Scan(&n))
return n
}
assert.Equal(t, 1, count("doc_type = 'observation' AND sqlite_id = 1"), "the fact document is gone")
assert.Equal(t, 1, count("doc_id = 'obs_1_narrative' AND scope = 'project'"), "and the narrative carries the new scope")
assert.Equal(t, 1, count("doc_id = 'obs_2_narrative' AND scope = 'global'"), "another note is untouched")
assert.Equal(t, 1, count("doc_id = 'summary_1_request'"), "so is a summary with the same id")
require.NoError(t, client.ReplaceDocuments(ctx, "observation", 1, nil), "no documents left: only the old ones are deleted")
assert.Zero(t, count("doc_type = 'observation' AND sqlite_id = 1"))
}
+3 -10
View File
@@ -22,11 +22,8 @@ func NewSync(client *Client) *Sync {
// SyncObservation syncs a single observation to the vector store. // SyncObservation syncs a single observation to the vector store.
func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error { func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error {
docs := s.formatObservationDocs(obs) docs := s.formatObservationDocs(obs)
if len(docs) == 0 { // Replace, not add: a note that is synced again (after an edit, a re-scope or a re-import) already has documents.
return nil if err := s.client.ReplaceDocuments(ctx, "observation", obs.ID, docs); err != nil {
}
if err := s.client.AddDocuments(ctx, docs); err != nil {
return fmt.Errorf("add observation docs: %w", err) return fmt.Errorf("add observation docs: %w", err)
} }
@@ -102,11 +99,7 @@ func (s *Sync) formatObservationDocs(obs *models.Observation) []Document {
// SyncSummary syncs a single session summary to the vector store. // SyncSummary syncs a single session summary to the vector store.
func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error { func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error {
docs := s.formatSummaryDocs(summary) docs := s.formatSummaryDocs(summary)
if len(docs) == 0 { if err := s.client.ReplaceDocuments(ctx, "session_summary", summary.ID, docs); err != nil {
return nil
}
if err := s.client.AddDocuments(ctx, docs); err != nil {
return fmt.Errorf("add summary docs: %w", err) return fmt.Errorf("add summary docs: %w", err)
} }
+54
View File
@@ -346,3 +346,57 @@ func TestSync_FormatObservationDocs_EmptyScope(t *testing.T) {
assert.Len(t, docs, 1) assert.Len(t, docs, 1)
assert.Equal(t, "project", docs[0].Metadata["scope"]) assert.Equal(t, "project", docs[0].Metadata["scope"])
} }
func vectorCount(t *testing.T, c *Client, where string, args ...any) int {
t.Helper()
var n int
require.NoError(t, c.db.QueryRow("SELECT COUNT(*) FROM vectors WHERE "+where, args...).Scan(&n))
return n
}
func TestSync_SyncObservation_AgainReplacesTheDocumentsOfTheNote(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
ctx := context.Background()
obs := &models.Observation{
ID: 7, SDKSessionID: "s", Project: "alpha", Type: models.ObsTypeDiscovery, Scope: models.ScopeGlobal,
Narrative: sql.NullString{String: "The queue keeps one consumer per tenant.", Valid: true},
Facts: []string{"one consumer per tenant", "retries are capped", "dead letters go to a second queue"},
}
other := &models.Observation{
ID: 8, SDKSessionID: "s", Project: "alpha", Type: models.ObsTypeDiscovery, Scope: models.ScopeProject,
Narrative: sql.NullString{String: "Releases are cut on Thursdays.", Valid: true},
}
require.NoError(t, sync.SyncObservation(ctx, obs))
require.NoError(t, sync.SyncObservation(ctx, other))
require.Equal(t, 4, vectorCount(t, client, "doc_type = 'observation' AND sqlite_id = ?", 7))
// The note was edited: one fact fewer, and it is a project note now.
obs.Facts = obs.Facts[:2]
obs.Scope = models.ScopeProject
require.NoError(t, sync.SyncObservation(ctx, obs), "syncing a note that already has documents is not an error")
assert.Equal(t, 3, vectorCount(t, client, "doc_type = 'observation' AND sqlite_id = ?", 7), "narrative and two facts; the dropped fact's document is gone")
assert.Equal(t, 0, vectorCount(t, client, "doc_id = ?", "obs_7_fact_2"))
assert.Equal(t, 3, vectorCount(t, client, "sqlite_id = ? AND scope = 'project'", 7), "the new scope is what the vectors carry")
assert.Equal(t, 1, vectorCount(t, client, "sqlite_id = ?", 8), "other notes are not touched")
}
func TestSync_SyncSummary_AgainReplacesTheDocumentsOfTheSummary(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
ctx := context.Background()
summary := &models.SessionSummary{
ID: 3, SDKSessionID: "s", Project: "alpha",
Request: sql.NullString{String: "Add the queue", Valid: true}, Learned: sql.NullString{String: "One consumer per tenant", Valid: true},
}
require.NoError(t, sync.SyncSummary(ctx, summary))
summary.Learned = sql.NullString{}
require.NoError(t, sync.SyncSummary(ctx, summary))
assert.Equal(t, 1, vectorCount(t, client, "doc_type = 'session_summary' AND sqlite_id = ?", 3), "the field that was emptied has no document any more")
}
+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 { for _, r := range results {
if r.Similarity >= cfg.DeduplicationThreshold { if r.Similarity >= cfg.DeduplicationThreshold {
obsID := extractObservationIDFromVectorDoc(r) obsID := extractObservationIDFromVectorDoc(r)
if obsID > 0 { if obsID > 0 && p.canMergeInto(ctx, obsID) {
return &DeduplicationResult{ return &DeduplicationResult{
ExistingID: obsID, ExistingID: obsID,
Similarity: r.Similarity, Similarity: r.Similarity,
@@ -64,6 +64,20 @@ func (p *Processor) checkVectorDeduplication(ctx context.Context, obs *models.Pa
return &DeduplicationResult{Action: "insert"} 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. // buildObservationSearchText creates searchable text from a parsed observation.
func buildObservationSearchText(obs *models.ParsedObservation) string { func buildObservationSearchText(obs *models.ParsedObservation) string {
var parts []string var parts []string
+33
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec" "github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "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) 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", "name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search", "description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
"version": "0.21.85", "version": "0.21.95",
"author": { "author": {
"name": "lukaszraczylo", "name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo" "url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic", "homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": { "releases": {
"latest": "0.21.85", "latest": "0.21.95",
"versions": { "versions": {
"0.21.85": { "0.21.95": {
"releaseDate": "2026-10-03", "releaseDate": "2026-10-04",
"downloads": { "downloads": {
"darwin-arm64": { "darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.85/claude-mnemonic_0.21.85_darwin_arm64.tar.gz", "url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_darwin_arm64.tar.gz",
"format": "tar.gz", "format": "tar.gz",
"sha256": "d526cf5a3c48f15ece1e0e55313c7446766b6b7c8b9dd23404af921015595bd9" "sha256": "3224be650ead03a23078f7033909ecaedf89c88d665b3aa83c70b2eb952722b9"
}, },
"linux-amd64": { "linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.85/claude-mnemonic_0.21.85_linux_amd64.tar.gz", "url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_linux_amd64.tar.gz",
"format": "tar.gz", "format": "tar.gz",
"sha256": "7510acf546aaf3f4583c125028bf42be03a875d8a91ec5341c5914997579bafa" "sha256": "4af6927b7857b03143e6eb817da7f44c39dd2b5674a7d1b7f505db7c3dd25b2f"
}, },
"windows-amd64": { "windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.85/claude-mnemonic_0.21.85_windows_amd64.zip", "url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.95/claude-mnemonic_0.21.95_windows_amd64.zip",
"format": "zip", "format": "zip",
"sha256": "fea15fe6e4155161db74bf3b76505f405a6ed107a5e856e418d0d13be414ecec" "sha256": "d779920957eea6b21a344556b5a36ec2a1a6c596e095575e73110c5cb997fc1a"
} }
} }
} }
+81 -81
View File
@@ -156,9 +156,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.150.0", "version": "0.152.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -166,9 +166,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -183,9 +183,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -200,9 +200,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -217,9 +217,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -234,9 +234,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -251,9 +251,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -268,9 +268,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -288,9 +288,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -308,9 +308,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -328,9 +328,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -348,9 +348,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -368,9 +368,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -388,9 +388,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -405,9 +405,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -422,9 +422,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -802,9 +802,9 @@
"peer": true "peer": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.6.3", "version": "26.6.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -908,9 +908,9 @@
} }
}, },
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.12.tgz",
"integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", "integrity": "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1458,13 +1458,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.9", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.150.0", "@oxc-project/types": "=0.152.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -1474,21 +1474,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm-eabi": "1.2.12",
"@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-android-arm64": "1.2.12",
"@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.12",
"@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.12",
"@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
"@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.12",
"@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.12",
"@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
"@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.12",
"@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.12",
"@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.12",
"@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.12",
"@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.12",
"@rolldown/binding-win32-x64-msvc": "1.2.9" "@rolldown/binding-win32-x64-msvc": "1.2.12"
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
@@ -1624,16 +1624,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
"postcss": "^8.5.28", "postcss": "^8.5.28",
"rolldown": "~1.2.6", "rolldown": "~1.2.11",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@@ -2003,14 +2003,14 @@
} }
}, },
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.12.tgz",
"integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", "integrity": "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@volar/typescript": "2.4.28", "@volar/typescript": "2.4.28",
"@vue/language-core": "3.3.11" "@vue/language-core": "3.3.12"
}, },
"bin": { "bin": {
"vue-tsc": "bin/vue-tsc.js" "vue-tsc": "bin/vue-tsc.js"