mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2886bbedb | ||
|
|
9b6e5f7648 | ||
|
|
055103cb3b | ||
|
|
67de0f0c84 | ||
|
|
eba3136fff | ||
|
|
17f7bffef5 | ||
|
|
0d75b867e6 | ||
|
|
a8e9d0f256 | ||
|
|
8857cc9c72 | ||
|
|
096490a2d3 | ||
|
|
eaf7c4ddca | ||
|
|
2d1f52cdf4 | ||
|
|
9d7959f98f | ||
|
|
daa9959961 | ||
|
|
f8e5b08de7 | ||
|
|
ad718f9292 |
@@ -1,4 +1,4 @@
|
|||||||
name: Autoupdate go.mod and go.sum
|
name: Update dependencies
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -6,16 +6,16 @@ on:
|
|||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
|
||||||
actions: write
|
actions: write
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
autoupdate:
|
update:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
release-workflow: "release.yaml"
|
node-working-directory: ui
|
||||||
lfs: false
|
require-node-tests: false
|
||||||
secrets: inherit
|
release-workflow: release.yaml
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/go-chi/chi/v5 v5.3.2
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/goccy/go-json v0.11.2
|
github.com/goccy/go-json v0.11.2
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
github.com/lukaszraczylo/oss-telemetry v0.2.5
|
||||||
github.com/mattn/go-sqlite3 v1.14.52
|
github.com/mattn/go-sqlite3 v1.14.52
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
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 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -500,15 +500,13 @@ func TestObservationStore_CleanupOldObservations(t *testing.T) {
|
|||||||
|
|
||||||
// Store observations beyond the limit WITHOUT async cleanup
|
// Store observations beyond the limit WITHOUT async cleanup
|
||||||
// We disable async cleanup by not setting cleanupFunc
|
// We disable async cleanup by not setting cleanupFunc
|
||||||
var allIDs []int64
|
|
||||||
for i := 0; i < 105; i++ {
|
for i := 0; i < 105; i++ {
|
||||||
observation := &models.ParsedObservation{
|
observation := &models.ParsedObservation{
|
||||||
Type: models.ObsTypeDiscovery,
|
Type: models.ObsTypeDiscovery,
|
||||||
Title: "Observation",
|
Title: "Observation",
|
||||||
}
|
}
|
||||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
allIDs = append(allIDs, id)
|
|
||||||
time.Sleep(2 * time.Millisecond) // Ensure different timestamps
|
time.Sleep(2 * time.Millisecond) // Ensure different timestamps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -218,9 +218,8 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Restart in background after response is sent
|
// Restart in background after response is sent
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.updater.Restart(); err != nil {
|
// Restart only returns when the new worker failed to start.
|
||||||
log.Error().Err(err).Msg("Failed to restart worker")
|
log.Error().Err(s.updater.Restart()).Msg("Failed to restart worker")
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,8 +243,7 @@ func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
|
|||||||
go func() {
|
go func() {
|
||||||
// Small delay to ensure response is sent
|
// Small delay to ensure response is sent
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
if err := s.updater.Restart(); err != nil {
|
// Restart only returns when the new worker failed to start.
|
||||||
log.Error().Err(err).Msg("Failed to restart worker")
|
log.Error().Err(s.updater.Restart()).Msg("Failed to restart worker")
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -206,14 +207,13 @@ func (pcrl *PerClientRateLimiter) Stats() map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PerClientRateLimitMiddleware creates middleware that applies per-client rate limiting.
|
// PerClientRateLimitMiddleware creates middleware that applies per-client rate limiting.
|
||||||
// Uses X-Forwarded-For or RemoteAddr to identify clients.
|
// Identifies clients by the peer IP of the connection; forwarding headers are spoofable and ignored.
|
||||||
func PerClientRateLimitMiddleware(limiter *PerClientRateLimiter) func(http.Handler) http.Handler {
|
func PerClientRateLimitMiddleware(limiter *PerClientRateLimiter) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get client identifier (prefer X-Real-IP from RealIP middleware)
|
|
||||||
clientKey := r.RemoteAddr
|
clientKey := r.RemoteAddr
|
||||||
if xff := r.Header.Get("X-Real-IP"); xff != "" {
|
if host, _, err := net.SplitHostPort(clientKey); err == nil {
|
||||||
clientKey = xff
|
clientKey = host
|
||||||
}
|
}
|
||||||
|
|
||||||
if !limiter.Allow(clientKey) {
|
if !limiter.Allow(clientKey) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPerClientRateLimitMiddleware_KeysOnPeerIP(t *testing.T) {
|
||||||
|
h := PerClientRateLimitMiddleware(NewPerClientRateLimiter(0.001, 1))(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
|
||||||
|
do := func(remote, realIP string) int {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = remote
|
||||||
|
if realIP != "" {
|
||||||
|
req.Header.Set("X-Real-IP", realIP)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := do("10.0.0.1:1111", "1.1.1.1"); got != http.StatusOK {
|
||||||
|
t.Fatalf("first request: got %d", got)
|
||||||
|
}
|
||||||
|
// Same peer, new port and forged header: must share the exhausted bucket.
|
||||||
|
if got := do("10.0.0.1:2222", "2.2.2.2"); got != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("spoofed header/new port bypassed limit: got %d", got)
|
||||||
|
}
|
||||||
|
// Different peer is unaffected.
|
||||||
|
if got := do("10.0.0.2:1111", ""); got != http.StatusOK {
|
||||||
|
t.Fatalf("other peer: got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package sdk
|
package sdk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -121,7 +123,7 @@ func TestCheckVectorDeduplication_NilClient(t *testing.T) {
|
|||||||
Narrative: "Some narrative text",
|
Narrative: "Some narrative text",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := p.checkVectorDeduplication(nil, obs, "test-project")
|
result := p.checkVectorDeduplication(context.Background(), obs, "test-project")
|
||||||
if result.Action != "insert" {
|
if result.Action != "insert" {
|
||||||
t.Errorf("expected Action='insert' when vectorClient is nil, got %q", result.Action)
|
t.Errorf("expected Action='insert' when vectorClient is nil, got %q", result.Action)
|
||||||
}
|
}
|
||||||
@@ -136,8 +138,40 @@ func TestCheckVectorDeduplication_EmptySearchText(t *testing.T) {
|
|||||||
// All empty fields
|
// All empty fields
|
||||||
}
|
}
|
||||||
|
|
||||||
result := p.checkVectorDeduplication(nil, obs, "test-project")
|
result := p.checkVectorDeduplication(context.Background(), obs, "test-project")
|
||||||
if result.Action != "insert" {
|
if result.Action != "insert" {
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1213,7 +1213,6 @@ func (s *Service) setupMiddleware() {
|
|||||||
|
|
||||||
s.router.Use(middleware.Logger)
|
s.router.Use(middleware.Logger)
|
||||||
s.router.Use(middleware.Recoverer)
|
s.router.Use(middleware.Recoverer)
|
||||||
s.router.Use(middleware.RealIP)
|
|
||||||
|
|
||||||
// Add security headers (X-Frame-Options, X-Content-Type-Options, CSP, etc.)
|
// Add security headers (X-Frame-Options, X-Content-Type-Options, CSP, etc.)
|
||||||
s.router.Use(SecurityHeaders)
|
s.router.Use(SecurityHeaders)
|
||||||
@@ -1227,7 +1226,7 @@ func (s *Service) setupMiddleware() {
|
|||||||
// Add gzip compression for responses >1KB (reduces bandwidth ~70% for JSON)
|
// Add gzip compression for responses >1KB (reduces bandwidth ~70% for JSON)
|
||||||
s.router.Use(middleware.Compress(5)) // Level 5 = good balance of speed vs compression
|
s.router.Use(middleware.Compress(5)) // Level 5 = good balance of speed vs compression
|
||||||
|
|
||||||
// Apply per-client rate limiting (after RealIP so we get the real client IP)
|
// Apply per-client rate limiting (keyed on the socket peer address; forwarding headers are untrusted)
|
||||||
if s.rateLimiter != nil {
|
if s.rateLimiter != nil {
|
||||||
s.router.Use(PerClientRateLimitMiddleware(s.rateLimiter))
|
s.router.Use(PerClientRateLimitMiddleware(s.rateLimiter))
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -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.69",
|
"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.69",
|
"latest": "0.21.95",
|
||||||
"versions": {
|
"versions": {
|
||||||
"0.21.69": {
|
"0.21.95": {
|
||||||
"releaseDate": "2026-09-30",
|
"releaseDate": "2026-10-04",
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.69/claude-mnemonic_0.21.69_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": "17234c1d07fa3b6b92ec910611c5cc7fbe7f3e5a1f11088a943785519fe2342c"
|
"sha256": "3224be650ead03a23078f7033909ecaedf89c88d665b3aa83c70b2eb952722b9"
|
||||||
},
|
},
|
||||||
"linux-amd64": {
|
"linux-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.69/claude-mnemonic_0.21.69_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": "86b9cd9bb5bc645b6f2e82c35997cbbfa4903c2a2135636d1f65abaaaf171839"
|
"sha256": "4af6927b7857b03143e6eb817da7f44c39dd2b5674a7d1b7f505db7c3dd25b2f"
|
||||||
},
|
},
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.69/claude-mnemonic_0.21.69_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": "618d537d96df4c91ee32c570de38cb392740d5d7202cbd0a732bbdea36cfbf31"
|
"sha256": "d779920957eea6b21a344556b5a36ec2a1a6c596e095575e73110c5cb997fc1a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+152
-86
@@ -15,7 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^25.9.8",
|
"@types/node": "^26.0.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.9",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
@@ -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"
|
||||||
],
|
],
|
||||||
@@ -680,6 +680,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
@@ -736,13 +802,13 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.8",
|
"version": "26.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
|
||||||
"integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==",
|
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": "~8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
@@ -842,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": {
|
||||||
@@ -1392,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": {
|
||||||
@@ -1408,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": {
|
||||||
@@ -1487,9 +1553,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.24.6",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1558,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": {
|
||||||
@@ -1937,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"
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^25.9.8",
|
"@types/node": "^26.0.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.9",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^25.9.8",
|
"@types/node": "^26.0.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.9",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user