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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/sin-code/internal/catalog/source_external.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,5 @@ var externalServers = []externalServer{
{name: "simone", namespace: "simone__*", short: "Simone code intelligence", description: "Simone-MCP server (AST/LSP code intelligence).", example: "simone__symbol_search 'Server.Start'", tags: []string{"external", "code"}},
{name: "symfonylens", namespace: "symfonylens__*", short: "Symfony lens", description: "SIN-Code-Symfony-Lens MCP server.", example: "symfonylens__analyze_routes /project", tags: []string{"external", "php"}},
{name: "websearch", namespace: "websearch__*", short: "Web search", description: "Go-native web_search_bundle MCP server (sin-websearch).", example: "websearch__search 'Go 1.24 release'", tags: []string{"external", "network"}},
{name: "native_websearch", namespace: "native_websearch__*", short: "Native web search", description: "Pure-Go in-process websearch (issue #381) — DuckDuckGo HTML endpoint over stdlib net/http with 15-minute LRU cache and token-bucket rate limiter; the external Python skill is no longer required.", example: "native_websearch__search 'Go 1.24 release'", tags: []string{"external", "network", "native"}},
}
12 changes: 12 additions & 0 deletions cmd/sin-code/internal/mcpclient/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ func DefaultServers() []ServerConfig {
// v3.22.0: sin-analyse-suite — multimodal preprocessing (image, video, PDF, logs, data, audio)
goNative("sin-analyse-suite", "sin-analyse", "serve"),

// v3.23.0 (issue #381): internal_native_websearch — pure-Go in-process
// websearch (cmd/sin-code/internal/native_websearch). DuckDuckGo HTML
// endpoint, stdlib net/http + LRU cache (15 min TTL) + token-bucket
// rate limiter; no Python dependency. Reserves the native_websearch__*
// tool namespace so the catalog + permission matrix recognise it.
// Implementation runs in-process behind the chat tools layer;
// sin-native-websearch binary is a future stdio shim. ConnectAll warns
// and skips the stdio spawn when the binary is absent; permission +
// catalog resolution continue to work.
goNative("internal_native_websearch", "sin-native-websearch", "serve"),

// External MCP server (Python stdio) — autodev-cli v0.4.0 (Bridged-External, never vendored)
{Name: "autodev", Transport: "stdio", Command: "autodev-mcp"},
}
Expand All @@ -83,6 +94,7 @@ func shortName(repo string) string {
m := map[string]string{
"web_search_bundle": "websearch",
"sin-analyse-suite": "analyse",
"internal_native_websearch": "native_websearch",
"SIN-Code-Websearch-Skill": "websearch",
"SIN-Code-Scheduler-Skill": "scheduler",
"SIN-Code-Goal-Mode-Skill": "goalmode",
Expand Down
162 changes: 162 additions & 0 deletions cmd/sin-code/internal/native_websearch/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: MIT
// Purpose: in-memory LRU+TTL cache for native websearch query results
// (issue #381). Race-safe, stdlib-only (container/list + sync.Mutex),
// no external deps. Bounded size + 15-minute default TTL honour mandate
// M2 (single static binary, no runtime dependencies).
package native_websearch

import (
"container/list"
"sync"
"time"
)

type Cache struct {
mu sync.Mutex
items map[string]*list.Element
order *list.List
maxSize int
ttl time.Duration
hits uint64
misses uint64
evicted uint64
}

type cacheEntry struct {
key string
value any
createdAt time.Time
}

// CacheStats is a snapshot of the cache footprint. All counters are
// monotonic since construction; the surface is purposely narrow so the
// `internal/ledger` consumer can pipe it through byte-stable telemetry
// without schema churn.
type CacheStats struct {
Size int `json:"size"`
MaxSize int `json:"max_size"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
Evicted uint64 `json:"evicted"`
TTL time.Duration `json:"ttl"`
}

// NewCache returns a Cache pre-sized to maxSize with the given TTL.
// maxSize <= 0 collapses to 1 so an empty cache cannot panic on insert;
// ttl <= 0 collapses to a 15-minute default — the canonical value
// named in the cache contract.
func NewCache(maxSize int, ttl time.Duration) *Cache {
if maxSize <= 0 {
maxSize = 1
}
if ttl <= 0 {
ttl = 15 * time.Minute
}
return &Cache{
items: make(map[string]*list.Element, maxSize),
order: list.New(),
maxSize: maxSize,
ttl: ttl,
}
}

// Get returns the cached value for key and a bool indicating whether it
// is still fresh. Expired or missing keys return (nil, false) so the
// caller can fall through to the network path.
func (c *Cache) Get(key string) (any, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.items[key]
if !ok {
c.misses++
return nil, false
}
entry := el.Value.(*cacheEntry)
if time.Since(entry.createdAt) > c.ttl {
c.order.Remove(el)
delete(c.items, key)
c.evicted++
c.misses++
return nil, false
}
c.order.MoveToFront(el)
c.hits++
return entry.value, true
}

// Put inserts or replaces the value for key. Insertions beyond maxSize
// evict the least-recently-used entry; the insertion timestamp is the
// moment of Put, not Get, so a hot key that survives many Gets and then
// ages out gets a fresh TTL window on its next Put.
func (c *Cache) Put(key string, value any) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
entry := el.Value.(*cacheEntry)
entry.value = value
entry.createdAt = time.Now()
c.order.MoveToFront(el)
return
}
entry := &cacheEntry{key: key, value: value, createdAt: time.Now()}
el := c.order.PushFront(entry)
c.items[key] = el
for c.order.Len() > c.maxSize {
back := c.order.Back()
if back == nil {
break
}
old := back.Value.(*cacheEntry)
c.order.Remove(back)
delete(c.items, old.key)
c.evicted++
}
}

// Delete removes key if present. No-op for unknown keys.
func (c *Cache) Delete(key string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
c.order.Remove(el)
delete(c.items, key)
}
}

// Len returns the current item count. Useful for tests asserting that
// the eviction loop actually fires.
func (c *Cache) Len() int {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
return c.order.Len()
}

// Stats returns a snapshot of the cache's counters. The output is taken
// under a single lock so hits + misses + size cannot disagree mid-flight.
func (c *Cache) Stats() CacheStats {
if c == nil {
return CacheStats{}
}
c.mu.Lock()
defer c.mu.Unlock()
return CacheStats{
Size: c.order.Len(),
MaxSize: c.maxSize,
Hits: c.hits,
Misses: c.misses,
Evicted: c.evicted,
TTL: c.ttl,
}
}
78 changes: 78 additions & 0 deletions cmd/sin-code/internal/native_websearch/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
package native_websearch

import (
"testing"
"time"
)

func TestNewCacheDefaults(t *testing.T) {
c := NewCache(0, 0)
if c.maxSize != 1 {
t.Errorf("maxSize default = %d, want 1", c.maxSize)
}
if c.ttl != 15*time.Minute {
t.Errorf("TTL default = %v, want 15m", c.ttl)
}
}

func TestCacheEviction(t *testing.T) {
c := NewCache(2, time.Minute)
c.Put("a", 1)
c.Put("b", 2)
if c.Len() != 2 {
t.Fatalf("len=%d, want 2", c.Len())
}
c.Put("c", 3)
if c.Len() != 2 {
t.Fatalf("len after Put(c)=%d, want 2", c.Len())
}
if _, ok := c.Get("a"); ok {
t.Errorf("a should be evicted (LRU policy); was retrieved")
}
if _, ok := c.Get("c"); !ok {
t.Errorf("c should still be present; was missing")
}
}

func TestCacheTTLExpiry(t *testing.T) {
c := NewCache(4, 20*time.Millisecond)
c.Put("k", "v")
if _, ok := c.Get("k"); !ok {
t.Fatal("Get before TTL expiry returned miss")
}
time.Sleep(40 * time.Millisecond)
if _, ok := c.Get("k"); ok {
t.Fatal("Get after TTL expiry returned hit; want miss")
}
s := c.Stats()
if s.Evicted == 0 {
t.Errorf("evicted count = 0 after expiry; want >=1")
}
}

func TestCacheNilSafe(t *testing.T) {
var c *Cache
if _, ok := c.Get("k"); ok {
t.Error("nil.Get returned hit")
}
c.Put("k", 1)
if got := c.Len(); got != 0 {
t.Errorf("nil.Len=%d, want 0", got)
}
if got := c.Stats(); got != (CacheStats{}) {
t.Errorf("nil.Stats=%+v, want zero", got)
}
}

func TestCacheReplace(t *testing.T) {
c := NewCache(2, time.Minute)
c.Put("k", "v1")
c.Put("k", "v2")
if c.Len() != 1 {
t.Fatalf("len after replace = %d, want 1", c.Len())
}
if v, ok := c.Get("k"); !ok || v.(string) != "v2" {
t.Errorf("Get after replace = %v, %v; want v2 true", v, ok)
}
}
Loading
Loading