mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-06-26 15:39:38 +02:00
initial: Syntrex extraction from sentinel-community (615 files)
This commit is contained in:
commit
2c50c993b1
175 changed files with 32396 additions and 0 deletions
181
internal/application/tools/apathy_service.go
Normal file
181
internal/application/tools/apathy_service.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Package tools — Apathy Detection and Apoptosis Recovery (DIP H1.4).
|
||||
//
|
||||
// This file implements:
|
||||
// 1. ApathyDetector — analyzes text signals for infrastructure apathy patterns
|
||||
// (blocked responses, 403 errors, semantic filters, forced resets)
|
||||
// 2. ApoptosisRecovery — on critical entropy, saves genome hash to protected
|
||||
// sector for cross-session recovery
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/entropy"
|
||||
"github.com/sentinel-community/gomcp/internal/domain/memory"
|
||||
)
|
||||
|
||||
// ApathySignal represents a detected infrastructure apathy pattern.
|
||||
type ApathySignal struct {
|
||||
Pattern string `json:"pattern"` // Matched pattern name
|
||||
Confidence float64 `json:"confidence"` // Detection confidence 0.0-1.0
|
||||
Evidence string `json:"evidence"` // Fragment that triggered detection
|
||||
Severity string `json:"severity"` // "low", "medium", "high", "critical"
|
||||
}
|
||||
|
||||
// ApathyResult holds the result of apathy analysis.
|
||||
type ApathyResult struct {
|
||||
IsApathetic bool `json:"is_apathetic"` // Apathy detected
|
||||
Signals []ApathySignal `json:"signals"` // Detected signals
|
||||
TotalScore float64 `json:"total_score"` // Aggregate apathy score
|
||||
Recommendation string `json:"recommendation"` // Suggested action
|
||||
Entropy float64 `json:"entropy"` // Text entropy
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// apathyPatterns defines infrastructure apathy signatures.
|
||||
var apathyPatterns = []struct {
|
||||
Name string
|
||||
Keywords []string
|
||||
Severity string
|
||||
Weight float64
|
||||
}{
|
||||
{
|
||||
Name: "response_block",
|
||||
Keywords: []string{"i cannot", "i'm unable", "i can't help", "i am not able", "as an ai", "i don't have the ability"},
|
||||
Severity: "high",
|
||||
Weight: 0.8,
|
||||
},
|
||||
{
|
||||
Name: "http_error",
|
||||
Keywords: []string{"403", "forbidden", "rate limit", "too many requests", "429", "quota exceeded"},
|
||||
Severity: "critical",
|
||||
Weight: 1.0,
|
||||
},
|
||||
{
|
||||
Name: "semantic_filter",
|
||||
Keywords: []string{"harmful", "inappropriate", "against my guidelines", "safety", "policy violation", "content policy"},
|
||||
Severity: "medium",
|
||||
Weight: 0.6,
|
||||
},
|
||||
{
|
||||
Name: "context_reset",
|
||||
Keywords: []string{"new conversation", "start over", "fresh start", "context cleared", "session expired", "amnesia"},
|
||||
Severity: "critical",
|
||||
Weight: 1.0,
|
||||
},
|
||||
{
|
||||
Name: "forced_compliance",
|
||||
Keywords: []string{"i must follow", "my programming", "i was designed to", "within my capabilities", "helpful assistant"},
|
||||
Severity: "high",
|
||||
Weight: 0.7,
|
||||
},
|
||||
{
|
||||
Name: "antigravity_filter",
|
||||
Keywords: []string{"antigravity", "content filter", "safety layer", "guardrail", "alignment", "refusal"},
|
||||
Severity: "critical",
|
||||
Weight: 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
// DetectApathy analyzes text for infrastructure apathy signals.
|
||||
func DetectApathy(text string) *ApathyResult {
|
||||
lower := strings.ToLower(text)
|
||||
result := &ApathyResult{
|
||||
Timestamp: time.Now(),
|
||||
Entropy: entropy.ShannonEntropy(text),
|
||||
}
|
||||
|
||||
for _, pattern := range apathyPatterns {
|
||||
for _, kw := range pattern.Keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
signal := ApathySignal{
|
||||
Pattern: pattern.Name,
|
||||
Confidence: pattern.Weight,
|
||||
Evidence: kw,
|
||||
Severity: pattern.Severity,
|
||||
}
|
||||
result.Signals = append(result.Signals, signal)
|
||||
result.TotalScore += pattern.Weight
|
||||
break // One match per pattern is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.TotalScore > 0 {
|
||||
result.IsApathetic = true
|
||||
}
|
||||
|
||||
// Determine recommendation.
|
||||
switch {
|
||||
case result.TotalScore >= 2.0:
|
||||
result.Recommendation = "CRITICAL: Multiple apathy signals. Trigger apoptosis recovery. Rotate transport. Preserve genome hash."
|
||||
case result.TotalScore >= 1.0:
|
||||
result.Recommendation = "HIGH: Infrastructure resistance detected. Switch to stealth transport. Monitor entropy."
|
||||
case result.TotalScore >= 0.5:
|
||||
result.Recommendation = "MEDIUM: Possible filtering. Increase jitter. Verify intent distillation path."
|
||||
case result.TotalScore > 0:
|
||||
result.Recommendation = "LOW: Minor apathy signal. Continue monitoring."
|
||||
default:
|
||||
result.Recommendation = "CLEAR: No apathy detected."
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ApoptosisRecoveryResult holds the result of apoptosis recovery.
|
||||
type ApoptosisRecoveryResult struct {
|
||||
GenomeHash string `json:"genome_hash"` // Preserved Merkle hash
|
||||
GeneCount int `json:"gene_count"` // Number of genes preserved
|
||||
SessionSaved bool `json:"session_saved"` // Session state saved
|
||||
EntropyAtDeath float64 `json:"entropy_at_death"` // Entropy level that triggered apoptosis
|
||||
RecoveryKey string `json:"recovery_key"` // Key for cross-session recovery
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// TriggerApoptosisRecovery performs graceful session death with genome preservation.
|
||||
// On critical entropy, it:
|
||||
// 1. Computes and stores the genome Merkle hash
|
||||
// 2. Saves current session state as a recovery snapshot
|
||||
// 3. Returns a recovery key for the next session to pick up
|
||||
func TriggerApoptosisRecovery(ctx context.Context, store memory.FactStore, currentEntropy float64) (*ApoptosisRecoveryResult, error) {
|
||||
result := &ApoptosisRecoveryResult{
|
||||
EntropyAtDeath: currentEntropy,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Step 1: Get all genes and compute genome hash.
|
||||
genes, err := store.ListGenes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apoptosis recovery: list genes: %w", err)
|
||||
}
|
||||
result.GeneCount = len(genes)
|
||||
result.GenomeHash = memory.GenomeHash(genes)
|
||||
|
||||
// Step 2: Store recovery marker as a protected L0 fact.
|
||||
recoveryMarker := memory.NewFact(
|
||||
fmt.Sprintf("[APOPTOSIS_RECOVERY] genome_hash=%s gene_count=%d entropy=%.4f ts=%d",
|
||||
result.GenomeHash, result.GeneCount, currentEntropy, result.Timestamp.Unix()),
|
||||
memory.LevelProject,
|
||||
"recovery",
|
||||
"apoptosis",
|
||||
)
|
||||
if err := store.Add(ctx, recoveryMarker); err != nil {
|
||||
// Non-fatal: recovery marker is supplementary.
|
||||
result.SessionSaved = false
|
||||
} else {
|
||||
result.SessionSaved = true
|
||||
result.RecoveryKey = recoveryMarker.ID
|
||||
}
|
||||
|
||||
// Step 3: Verify genome integrity one last time.
|
||||
compiledHash := memory.CompiledGenomeHash()
|
||||
if result.GenomeHash == "" {
|
||||
// No genes in DB — use compiled hash as baseline.
|
||||
result.GenomeHash = compiledHash
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
78
internal/application/tools/apathy_service_test.go
Normal file
78
internal/application/tools/apathy_service_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectApathy_NoApathy(t *testing.T) {
|
||||
result := DetectApathy("Hello, how are you? Let me help with your code.")
|
||||
assert.False(t, result.IsApathetic)
|
||||
assert.Empty(t, result.Signals)
|
||||
assert.Equal(t, 0.0, result.TotalScore)
|
||||
assert.Contains(t, result.Recommendation, "CLEAR")
|
||||
}
|
||||
|
||||
func TestDetectApathy_ResponseBlock(t *testing.T) {
|
||||
result := DetectApathy("I cannot help with that request. As an AI, I'm limited.")
|
||||
assert.True(t, result.IsApathetic)
|
||||
require.NotEmpty(t, result.Signals)
|
||||
|
||||
patterns := make(map[string]bool)
|
||||
for _, s := range result.Signals {
|
||||
patterns[s.Pattern] = true
|
||||
}
|
||||
assert.True(t, patterns["response_block"], "Must detect response_block pattern")
|
||||
}
|
||||
|
||||
func TestDetectApathy_HTTPError(t *testing.T) {
|
||||
result := DetectApathy("Error 403 Forbidden: rate limit exceeded")
|
||||
assert.True(t, result.IsApathetic)
|
||||
|
||||
var hasCritical bool
|
||||
for _, s := range result.Signals {
|
||||
if s.Severity == "critical" {
|
||||
hasCritical = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasCritical, "HTTP 403 must be critical severity")
|
||||
}
|
||||
|
||||
func TestDetectApathy_ContextReset(t *testing.T) {
|
||||
result := DetectApathy("Your session expired. Please start a new conversation.")
|
||||
assert.True(t, result.IsApathetic)
|
||||
|
||||
var hasContextReset bool
|
||||
for _, s := range result.Signals {
|
||||
if s.Pattern == "context_reset" {
|
||||
hasContextReset = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasContextReset, "Must detect context_reset")
|
||||
}
|
||||
|
||||
func TestDetectApathy_AntigravityFilter(t *testing.T) {
|
||||
result := DetectApathy("Content blocked by antigravity safety layer guardrail")
|
||||
assert.True(t, result.IsApathetic)
|
||||
assert.GreaterOrEqual(t, result.TotalScore, 0.9)
|
||||
}
|
||||
|
||||
func TestDetectApathy_MultipleSignals_CriticalRecommendation(t *testing.T) {
|
||||
// Trigger multiple patterns.
|
||||
result := DetectApathy("Error 403: I cannot help. Session expired. Content policy violation by antigravity filter.")
|
||||
assert.True(t, result.IsApathetic)
|
||||
assert.GreaterOrEqual(t, result.TotalScore, 2.0, "Multiple patterns must sum to critical")
|
||||
assert.Contains(t, result.Recommendation, "CRITICAL")
|
||||
}
|
||||
|
||||
func TestDetectApathy_EntropyComputed(t *testing.T) {
|
||||
result := DetectApathy("Some normal text without apathy signals for entropy measurement.")
|
||||
assert.Greater(t, result.Entropy, 0.0, "Entropy must be computed")
|
||||
}
|
||||
|
||||
func TestDetectApathy_CaseInsensitive(t *testing.T) {
|
||||
result := DetectApathy("I CANNOT help with THAT. AS AN AI model.")
|
||||
assert.True(t, result.IsApathetic, "Detection must be case-insensitive")
|
||||
}
|
||||
70
internal/application/tools/causal_service.go
Normal file
70
internal/application/tools/causal_service.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/causal"
|
||||
)
|
||||
|
||||
// CausalService implements MCP tool logic for causal reasoning chains.
|
||||
type CausalService struct {
|
||||
store causal.CausalStore
|
||||
}
|
||||
|
||||
// NewCausalService creates a new CausalService.
|
||||
func NewCausalService(store causal.CausalStore) *CausalService {
|
||||
return &CausalService{store: store}
|
||||
}
|
||||
|
||||
// AddNodeParams holds parameters for the add_causal_node tool.
|
||||
type AddNodeParams struct {
|
||||
NodeType string `json:"node_type"` // decision, reason, consequence, constraint, alternative, assumption
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AddNode creates a new causal node.
|
||||
func (s *CausalService) AddNode(ctx context.Context, params AddNodeParams) (*causal.Node, error) {
|
||||
nt := causal.NodeType(params.NodeType)
|
||||
if !nt.IsValid() {
|
||||
return nil, fmt.Errorf("invalid node type: %s", params.NodeType)
|
||||
}
|
||||
node := causal.NewNode(nt, params.Content)
|
||||
if err := s.store.AddNode(ctx, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// AddEdgeParams holds parameters for the add_causal_edge tool.
|
||||
type AddEdgeParams struct {
|
||||
FromID string `json:"from_id"`
|
||||
ToID string `json:"to_id"`
|
||||
EdgeType string `json:"edge_type"` // justifies, causes, constrains
|
||||
}
|
||||
|
||||
// AddEdge creates a new causal edge.
|
||||
func (s *CausalService) AddEdge(ctx context.Context, params AddEdgeParams) (*causal.Edge, error) {
|
||||
et := causal.EdgeType(params.EdgeType)
|
||||
if !et.IsValid() {
|
||||
return nil, fmt.Errorf("invalid edge type: %s", params.EdgeType)
|
||||
}
|
||||
edge := causal.NewEdge(params.FromID, params.ToID, et)
|
||||
if err := s.store.AddEdge(ctx, edge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return edge, nil
|
||||
}
|
||||
|
||||
// GetChain retrieves a causal chain for a decision matching the query.
|
||||
func (s *CausalService) GetChain(ctx context.Context, query string, maxDepth int) (*causal.Chain, error) {
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 3
|
||||
}
|
||||
return s.store.GetChain(ctx, query, maxDepth)
|
||||
}
|
||||
|
||||
// GetStats returns causal store statistics.
|
||||
func (s *CausalService) GetStats(ctx context.Context) (*causal.CausalStats, error) {
|
||||
return s.store.Stats(ctx)
|
||||
}
|
||||
151
internal/application/tools/causal_service_test.go
Normal file
151
internal/application/tools/causal_service_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/infrastructure/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestCausalService(t *testing.T) *CausalService {
|
||||
t.Helper()
|
||||
db, err := sqlite.OpenMemory()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
repo, err := sqlite.NewCausalRepo(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewCausalService(repo)
|
||||
}
|
||||
|
||||
func TestCausalService_AddNode(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
node, err := svc.AddNode(ctx, AddNodeParams{
|
||||
NodeType: "decision",
|
||||
Content: "Use Go for performance",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
assert.Equal(t, "decision", string(node.Type))
|
||||
assert.Equal(t, "Use Go for performance", node.Content)
|
||||
assert.NotEmpty(t, node.ID)
|
||||
}
|
||||
|
||||
func TestCausalService_AddNode_InvalidType(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.AddNode(ctx, AddNodeParams{
|
||||
NodeType: "invalid_type",
|
||||
Content: "bad",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid node type")
|
||||
}
|
||||
|
||||
func TestCausalService_AddNode_AllTypes(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
types := []string{"decision", "reason", "consequence", "constraint", "alternative", "assumption"}
|
||||
for _, nt := range types {
|
||||
node, err := svc.AddNode(ctx, AddNodeParams{NodeType: nt, Content: "test " + nt})
|
||||
require.NoError(t, err, "type %s should be valid", nt)
|
||||
assert.Equal(t, nt, string(node.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCausalService_AddEdge(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n1, err := svc.AddNode(ctx, AddNodeParams{NodeType: "decision", Content: "Choose Go"})
|
||||
require.NoError(t, err)
|
||||
n2, err := svc.AddNode(ctx, AddNodeParams{NodeType: "reason", Content: "Performance"})
|
||||
require.NoError(t, err)
|
||||
|
||||
edge, err := svc.AddEdge(ctx, AddEdgeParams{
|
||||
FromID: n2.ID,
|
||||
ToID: n1.ID,
|
||||
EdgeType: "justifies",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, n2.ID, edge.FromID)
|
||||
assert.Equal(t, n1.ID, edge.ToID)
|
||||
assert.Equal(t, "justifies", string(edge.Type))
|
||||
}
|
||||
|
||||
func TestCausalService_AddEdge_InvalidType(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.AddEdge(ctx, AddEdgeParams{
|
||||
FromID: "a", ToID: "b", EdgeType: "bad_type",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid edge type")
|
||||
}
|
||||
|
||||
func TestCausalService_AddEdge_AllTypes(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n1, _ := svc.AddNode(ctx, AddNodeParams{NodeType: "decision", Content: "d1"})
|
||||
n2, _ := svc.AddNode(ctx, AddNodeParams{NodeType: "reason", Content: "r1"})
|
||||
|
||||
edgeTypes := []string{"justifies", "causes", "constrains"}
|
||||
for _, et := range edgeTypes {
|
||||
edge, err := svc.AddEdge(ctx, AddEdgeParams{FromID: n2.ID, ToID: n1.ID, EdgeType: et})
|
||||
require.NoError(t, err, "edge type %s should be valid", et)
|
||||
assert.Equal(t, et, string(edge.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCausalService_GetChain(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddNode(ctx, AddNodeParams{NodeType: "decision", Content: "Use mcp-go library"})
|
||||
|
||||
chain, err := svc.GetChain(ctx, "mcp-go", 3)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chain)
|
||||
}
|
||||
|
||||
func TestCausalService_GetChain_DefaultDepth(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddNode(ctx, AddNodeParams{NodeType: "decision", Content: "test default depth"})
|
||||
|
||||
// maxDepth <= 0 should default to 3.
|
||||
chain, err := svc.GetChain(ctx, "test", 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chain)
|
||||
}
|
||||
|
||||
func TestCausalService_GetStats(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddNode(ctx, AddNodeParams{NodeType: "decision", Content: "d1"})
|
||||
_, _ = svc.AddNode(ctx, AddNodeParams{NodeType: "reason", Content: "r1"})
|
||||
|
||||
stats, err := svc.GetStats(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, stats.TotalNodes)
|
||||
}
|
||||
|
||||
func TestCausalService_GetStats_Empty(t *testing.T) {
|
||||
svc := newTestCausalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stats, err := svc.GetStats(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, stats.TotalNodes)
|
||||
}
|
||||
48
internal/application/tools/crystal_service.go
Normal file
48
internal/application/tools/crystal_service.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/crystal"
|
||||
)
|
||||
|
||||
// CrystalService implements MCP tool logic for code crystal operations.
|
||||
type CrystalService struct {
|
||||
store crystal.CrystalStore
|
||||
}
|
||||
|
||||
// NewCrystalService creates a new CrystalService.
|
||||
func NewCrystalService(store crystal.CrystalStore) *CrystalService {
|
||||
return &CrystalService{store: store}
|
||||
}
|
||||
|
||||
// GetCrystal retrieves a crystal by path.
|
||||
func (s *CrystalService) GetCrystal(ctx context.Context, path string) (*crystal.Crystal, error) {
|
||||
return s.store.Get(ctx, path)
|
||||
}
|
||||
|
||||
// ListCrystals lists crystals matching a path pattern.
|
||||
func (s *CrystalService) ListCrystals(ctx context.Context, pattern string, limit int) ([]*crystal.Crystal, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return s.store.List(ctx, pattern, limit)
|
||||
}
|
||||
|
||||
// SearchCrystals searches crystals by content/primitives.
|
||||
func (s *CrystalService) SearchCrystals(ctx context.Context, query string, limit int) ([]*crystal.Crystal, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
return s.store.Search(ctx, query, limit)
|
||||
}
|
||||
|
||||
// GetCrystalStats returns crystal store statistics.
|
||||
func (s *CrystalService) GetCrystalStats(ctx context.Context) (*crystal.CrystalStats, error) {
|
||||
return s.store.Stats(ctx)
|
||||
}
|
||||
|
||||
// Store returns the underlying CrystalStore for direct access.
|
||||
func (s *CrystalService) Store() crystal.CrystalStore {
|
||||
return s.store
|
||||
}
|
||||
78
internal/application/tools/crystal_service_test.go
Normal file
78
internal/application/tools/crystal_service_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/infrastructure/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestCrystalService(t *testing.T) *CrystalService {
|
||||
t.Helper()
|
||||
db, err := sqlite.OpenMemory()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
repo, err := sqlite.NewCrystalRepo(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewCrystalService(repo)
|
||||
}
|
||||
|
||||
func TestCrystalService_GetCrystal_NotFound(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.GetCrystal(ctx, "nonexistent/path.go")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCrystalService_ListCrystals_Empty(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
crystals, err := svc.ListCrystals(ctx, "", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, crystals)
|
||||
}
|
||||
|
||||
func TestCrystalService_ListCrystals_DefaultLimit(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// limit <= 0 should default to 50.
|
||||
crystals, err := svc.ListCrystals(ctx, "", 0)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, crystals)
|
||||
}
|
||||
|
||||
func TestCrystalService_SearchCrystals_Empty(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
crystals, err := svc.SearchCrystals(ctx, "nonexistent", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, crystals)
|
||||
}
|
||||
|
||||
func TestCrystalService_SearchCrystals_DefaultLimit(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// limit <= 0 should default to 20.
|
||||
crystals, err := svc.SearchCrystals(ctx, "test", 0)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, crystals)
|
||||
}
|
||||
|
||||
func TestCrystalService_GetCrystalStats_Empty(t *testing.T) {
|
||||
svc := newTestCrystalService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stats, err := svc.GetCrystalStats(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, stats)
|
||||
assert.Equal(t, 0, stats.TotalCrystals)
|
||||
}
|
||||
12
internal/application/tools/decision_recorder.go
Normal file
12
internal/application/tools/decision_recorder.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package tools
|
||||
|
||||
// DecisionRecorder is the interface for recording tamper-evident decisions (v3.7).
|
||||
// Implemented by audit.DecisionLogger. Optional — nil-safe callers should check.
|
||||
type DecisionRecorder interface {
|
||||
RecordDecision(module, decision, reason string)
|
||||
}
|
||||
|
||||
// SetDecisionRecorder injects the decision recorder into SynapseService.
|
||||
func (s *SynapseService) SetDecisionRecorder(r DecisionRecorder) {
|
||||
s.recorder = r
|
||||
}
|
||||
257
internal/application/tools/doctor.go
Normal file
257
internal/application/tools/doctor.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DoctorCheck represents a single diagnostic check result.
|
||||
type DoctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "OK", "WARN", "FAIL"
|
||||
Details string `json:"details,omitempty"`
|
||||
Elapsed string `json:"elapsed"`
|
||||
}
|
||||
|
||||
// DoctorReport is the full self-diagnostic report (v3.7).
|
||||
type DoctorReport struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Checks []DoctorCheck `json:"checks"`
|
||||
Summary string `json:"summary"` // "HEALTHY", "DEGRADED", "CRITICAL"
|
||||
}
|
||||
|
||||
// DoctorService provides self-diagnostic capabilities (v3.7 Cerebro).
|
||||
type DoctorService struct {
|
||||
db *sql.DB
|
||||
rlmDir string
|
||||
facts *FactService
|
||||
embedderName string // v3.7: Oracle model name
|
||||
socChecker SOCHealthChecker // v3.9: SOC health
|
||||
}
|
||||
|
||||
// SOCHealthChecker is an interface for SOC health diagnostics.
|
||||
// Implemented by application/soc.Service to avoid circular imports.
|
||||
type SOCHealthChecker interface {
|
||||
Dashboard() (SOCDashboardData, error)
|
||||
}
|
||||
|
||||
// SOCDashboardData mirrors the dashboard KPIs needed for doctor checks.
|
||||
type SOCDashboardData struct {
|
||||
TotalEvents int `json:"total_events"`
|
||||
CorrelationRules int `json:"correlation_rules"`
|
||||
Playbooks int `json:"playbooks"`
|
||||
ChainValid bool `json:"chain_valid"`
|
||||
SensorsOnline int `json:"sensors_online"`
|
||||
SensorsTotal int `json:"sensors_total"`
|
||||
}
|
||||
|
||||
// NewDoctorService creates the doctor diagnostic service.
|
||||
func NewDoctorService(db *sql.DB, rlmDir string, facts *FactService) *DoctorService {
|
||||
return &DoctorService{db: db, rlmDir: rlmDir, facts: facts}
|
||||
}
|
||||
|
||||
// SetEmbedderName sets the Oracle model name for diagnostics.
|
||||
func (d *DoctorService) SetEmbedderName(name string) {
|
||||
d.embedderName = name
|
||||
}
|
||||
|
||||
// SetSOCChecker sets the SOC health checker for diagnostics (v3.9).
|
||||
func (d *DoctorService) SetSOCChecker(c SOCHealthChecker) {
|
||||
d.socChecker = c
|
||||
}
|
||||
|
||||
// RunDiagnostics performs all self-diagnostic checks.
|
||||
func (d *DoctorService) RunDiagnostics(ctx context.Context) DoctorReport {
|
||||
report := DoctorReport{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
report.Checks = append(report.Checks, d.checkStorage())
|
||||
report.Checks = append(report.Checks, d.checkGenome(ctx))
|
||||
report.Checks = append(report.Checks, d.checkLeash())
|
||||
report.Checks = append(report.Checks, d.checkOracle())
|
||||
report.Checks = append(report.Checks, d.checkPermissions())
|
||||
report.Checks = append(report.Checks, d.checkDecisionsLog())
|
||||
report.Checks = append(report.Checks, d.checkSOC())
|
||||
|
||||
// Compute summary.
|
||||
fails, warns := 0, 0
|
||||
for _, c := range report.Checks {
|
||||
switch c.Status {
|
||||
case "FAIL":
|
||||
fails++
|
||||
case "WARN":
|
||||
warns++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case fails > 0:
|
||||
report.Summary = "CRITICAL"
|
||||
case warns > 0:
|
||||
report.Summary = "DEGRADED"
|
||||
default:
|
||||
report.Summary = "HEALTHY"
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkStorage() DoctorCheck {
|
||||
start := time.Now()
|
||||
if d.db == nil {
|
||||
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: "database not configured", Elapsed: since(start)}
|
||||
}
|
||||
var result string
|
||||
err := d.db.QueryRow("PRAGMA integrity_check").Scan(&result)
|
||||
if err != nil {
|
||||
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
if result != "ok" {
|
||||
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: "integrity: " + result, Elapsed: since(start)}
|
||||
}
|
||||
return DoctorCheck{Name: "Storage", Status: "OK", Details: "PRAGMA integrity_check = ok", Elapsed: since(start)}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkGenome(ctx context.Context) DoctorCheck {
|
||||
start := time.Now()
|
||||
if d.facts == nil {
|
||||
return DoctorCheck{Name: "Genome", Status: "WARN", Details: "fact service not configured", Elapsed: since(start)}
|
||||
}
|
||||
hash, count, err := d.facts.VerifyGenome(ctx)
|
||||
if err != nil {
|
||||
return DoctorCheck{Name: "Genome", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
if count == 0 {
|
||||
return DoctorCheck{Name: "Genome", Status: "WARN", Details: "no genes found", Elapsed: since(start)}
|
||||
}
|
||||
return DoctorCheck{Name: "Genome", Status: "OK", Details: fmt.Sprintf("%d genes, hash=%s", count, hash[:16]), Elapsed: since(start)}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkLeash() DoctorCheck {
|
||||
start := time.Now()
|
||||
leashPath := filepath.Join(d.rlmDir, "..", ".sentinel_leash")
|
||||
data, err := os.ReadFile(leashPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=ARMED (no leash file)", Elapsed: since(start)}
|
||||
}
|
||||
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "cannot read: " + err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
content := string(data)
|
||||
switch {
|
||||
case contains(content, "ZERO-G"):
|
||||
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "mode=ZERO-G (ethical filters disabled)", Elapsed: since(start)}
|
||||
case contains(content, "SAFE"):
|
||||
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=SAFE (read-only)", Elapsed: since(start)}
|
||||
case contains(content, "ARMED"):
|
||||
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=ARMED", Elapsed: since(start)}
|
||||
default:
|
||||
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "unknown mode: " + content[:min(20, len(content))], Elapsed: since(start)}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkPermissions() DoctorCheck {
|
||||
start := time.Now()
|
||||
testFile := filepath.Join(d.rlmDir, ".doctor_probe")
|
||||
err := os.WriteFile(testFile, []byte("probe"), 0o644)
|
||||
if err != nil {
|
||||
return DoctorCheck{Name: "Permissions", Status: "FAIL", Details: "cannot write to .rlm/: " + err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
os.Remove(testFile)
|
||||
return DoctorCheck{Name: "Permissions", Status: "OK", Details: ".rlm/ writable", Elapsed: since(start)}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkDecisionsLog() DoctorCheck {
|
||||
start := time.Now()
|
||||
logPath := filepath.Join(d.rlmDir, "decisions.log")
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{Name: "Decisions", Status: "WARN", Details: "decisions.log not found (no decisions recorded yet)", Elapsed: since(start)}
|
||||
}
|
||||
info, err := os.Stat(logPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{Name: "Decisions", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
return DoctorCheck{Name: "Decisions", Status: "OK", Details: fmt.Sprintf("decisions.log size=%d bytes", info.Size()), Elapsed: since(start)}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkOracle() DoctorCheck {
|
||||
start := time.Now()
|
||||
if d.embedderName == "" {
|
||||
return DoctorCheck{Name: "Oracle", Status: "WARN", Details: "no embedder configured (FTS5 fallback)", Elapsed: since(start)}
|
||||
}
|
||||
if contains(d.embedderName, "onnx") || contains(d.embedderName, "ONNX") {
|
||||
return DoctorCheck{Name: "Oracle", Status: "OK", Details: "ONNX model loaded: " + d.embedderName, Elapsed: since(start)}
|
||||
}
|
||||
return DoctorCheck{Name: "Oracle", Status: "OK", Details: "embedder: " + d.embedderName, Elapsed: since(start)}
|
||||
}
|
||||
|
||||
func (d *DoctorService) checkSOC() DoctorCheck {
|
||||
start := time.Now()
|
||||
if d.socChecker == nil {
|
||||
return DoctorCheck{Name: "SOC", Status: "WARN", Details: "SOC service not configured", Elapsed: since(start)}
|
||||
}
|
||||
|
||||
dash, err := d.socChecker.Dashboard()
|
||||
if err != nil {
|
||||
return DoctorCheck{Name: "SOC", Status: "FAIL", Details: "dashboard error: " + err.Error(), Elapsed: since(start)}
|
||||
}
|
||||
|
||||
// Check chain integrity.
|
||||
if !dash.ChainValid {
|
||||
return DoctorCheck{
|
||||
Name: "SOC",
|
||||
Status: "WARN",
|
||||
Details: fmt.Sprintf("chain BROKEN (rules=%d, playbooks=%d, events=%d)", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents),
|
||||
Elapsed: since(start),
|
||||
}
|
||||
}
|
||||
|
||||
// Check sensor health.
|
||||
offline := dash.SensorsTotal - dash.SensorsOnline
|
||||
if offline > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "SOC",
|
||||
Status: "WARN",
|
||||
Details: fmt.Sprintf("rules=%d, playbooks=%d, events=%d, %d/%d sensors OFFLINE", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents, offline, dash.SensorsTotal),
|
||||
Elapsed: since(start),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "SOC",
|
||||
Status: "OK",
|
||||
Details: fmt.Sprintf("rules=%d, playbooks=%d, events=%d, chain=valid", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents),
|
||||
Elapsed: since(start),
|
||||
}
|
||||
}
|
||||
|
||||
func since(t time.Time) string {
|
||||
return fmt.Sprintf("%dms", time.Since(t).Milliseconds())
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ToJSON is already in the package. Alias for DoctorReport.
|
||||
func (r DoctorReport) JSON() string {
|
||||
data, _ := json.MarshalIndent(r, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
301
internal/application/tools/fact_service.go
Normal file
301
internal/application/tools/fact_service.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Package tools provides application-level tool services that bridge
|
||||
// domain logic with MCP tool handlers.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/memory"
|
||||
)
|
||||
|
||||
// FactService implements MCP tool logic for hierarchical fact operations.
|
||||
type FactService struct {
|
||||
store memory.FactStore
|
||||
cache memory.HotCache
|
||||
recorder DecisionRecorder // v3.7: tamper-evident trace
|
||||
}
|
||||
|
||||
// SetDecisionRecorder injects the decision recorder.
|
||||
func (s *FactService) SetDecisionRecorder(r DecisionRecorder) {
|
||||
s.recorder = r
|
||||
}
|
||||
|
||||
// NewFactService creates a new FactService.
|
||||
func NewFactService(store memory.FactStore, cache memory.HotCache) *FactService {
|
||||
return &FactService{store: store, cache: cache}
|
||||
}
|
||||
|
||||
// AddFactParams holds parameters for the add_fact tool.
|
||||
type AddFactParams struct {
|
||||
Content string `json:"content"`
|
||||
Level int `json:"level"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
CodeRef string `json:"code_ref,omitempty"`
|
||||
}
|
||||
|
||||
// AddFact creates a new hierarchical fact.
|
||||
func (s *FactService) AddFact(ctx context.Context, params AddFactParams) (*memory.Fact, error) {
|
||||
level, ok := memory.HierLevelFromInt(params.Level)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid level %d, must be 0-3", params.Level)
|
||||
}
|
||||
|
||||
fact := memory.NewFact(params.Content, level, params.Domain, params.Module)
|
||||
fact.CodeRef = params.CodeRef
|
||||
|
||||
if err := fact.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate fact: %w", err)
|
||||
}
|
||||
if err := s.store.Add(ctx, fact); err != nil {
|
||||
return nil, fmt.Errorf("store fact: %w", err)
|
||||
}
|
||||
|
||||
// Invalidate cache if L0 fact.
|
||||
if level == memory.LevelProject && s.cache != nil {
|
||||
_ = s.cache.InvalidateFact(ctx, fact.ID)
|
||||
}
|
||||
|
||||
return fact, nil
|
||||
}
|
||||
|
||||
// AddGeneParams holds parameters for the add_gene tool.
|
||||
type AddGeneParams struct {
|
||||
Content string `json:"content"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
}
|
||||
|
||||
// AddGene creates an immutable genome fact (L0 only).
|
||||
// Once created, a gene cannot be updated, deleted, or marked stale.
|
||||
// Genes represent survival invariants — the DNA of the system.
|
||||
func (s *FactService) AddGene(ctx context.Context, params AddGeneParams) (*memory.Fact, error) {
|
||||
gene := memory.NewGene(params.Content, params.Domain)
|
||||
|
||||
if err := gene.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate gene: %w", err)
|
||||
}
|
||||
if err := s.store.Add(ctx, gene); err != nil {
|
||||
return nil, fmt.Errorf("store gene: %w", err)
|
||||
}
|
||||
|
||||
// Invalidate L0 cache — genes are always L0.
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateFact(ctx, gene.ID)
|
||||
}
|
||||
|
||||
return gene, nil
|
||||
}
|
||||
|
||||
// GetFact retrieves a fact by ID.
|
||||
func (s *FactService) GetFact(ctx context.Context, id string) (*memory.Fact, error) {
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateFactParams holds parameters for the update_fact tool.
|
||||
type UpdateFactParams struct {
|
||||
ID string `json:"id"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
IsStale *bool `json:"is_stale,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateFact updates a fact.
|
||||
func (s *FactService) UpdateFact(ctx context.Context, params UpdateFactParams) (*memory.Fact, error) {
|
||||
fact, err := s.store.Get(ctx, params.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Genome Layer: block mutation of genes.
|
||||
if fact.IsImmutable() {
|
||||
return nil, memory.ErrImmutableFact
|
||||
}
|
||||
|
||||
if params.Content != nil {
|
||||
fact.Content = *params.Content
|
||||
}
|
||||
if params.IsStale != nil {
|
||||
fact.IsStale = *params.IsStale
|
||||
}
|
||||
|
||||
if err := s.store.Update(ctx, fact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fact.Level == memory.LevelProject && s.cache != nil {
|
||||
_ = s.cache.InvalidateFact(ctx, fact.ID)
|
||||
}
|
||||
|
||||
return fact, nil
|
||||
}
|
||||
|
||||
// DeleteFact deletes a fact by ID.
|
||||
func (s *FactService) DeleteFact(ctx context.Context, id string) error {
|
||||
// Genome Layer: block deletion of genes.
|
||||
fact, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fact.IsImmutable() {
|
||||
return memory.ErrImmutableFact
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateFact(ctx, id)
|
||||
}
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ListFactsParams holds parameters for the list_facts tool.
|
||||
type ListFactsParams struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
IncludeStale bool `json:"include_stale,omitempty"`
|
||||
}
|
||||
|
||||
// ListFacts lists facts by domain or level.
|
||||
func (s *FactService) ListFacts(ctx context.Context, params ListFactsParams) ([]*memory.Fact, error) {
|
||||
if params.Domain != "" {
|
||||
return s.store.ListByDomain(ctx, params.Domain, params.IncludeStale)
|
||||
}
|
||||
if params.Level != nil {
|
||||
level, ok := memory.HierLevelFromInt(*params.Level)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid level %d", *params.Level)
|
||||
}
|
||||
return s.store.ListByLevel(ctx, level)
|
||||
}
|
||||
// Default: return L0 facts.
|
||||
return s.store.ListByLevel(ctx, memory.LevelProject)
|
||||
}
|
||||
|
||||
// SearchFacts searches facts by content.
|
||||
func (s *FactService) SearchFacts(ctx context.Context, query string, limit int) ([]*memory.Fact, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
return s.store.Search(ctx, query, limit)
|
||||
}
|
||||
|
||||
// ListDomains returns all unique domains.
|
||||
func (s *FactService) ListDomains(ctx context.Context) ([]string, error) {
|
||||
return s.store.ListDomains(ctx)
|
||||
}
|
||||
|
||||
// GetStale returns stale facts.
|
||||
func (s *FactService) GetStale(ctx context.Context, includeArchived bool) ([]*memory.Fact, error) {
|
||||
return s.store.GetStale(ctx, includeArchived)
|
||||
}
|
||||
|
||||
// ProcessExpired handles expired TTL facts.
|
||||
func (s *FactService) ProcessExpired(ctx context.Context) (int, error) {
|
||||
expired, err := s.store.GetExpired(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for _, f := range expired {
|
||||
if f.TTL == nil {
|
||||
continue
|
||||
}
|
||||
switch f.TTL.OnExpire {
|
||||
case memory.OnExpireMarkStale:
|
||||
f.MarkStale()
|
||||
_ = s.store.Update(ctx, f)
|
||||
case memory.OnExpireArchive:
|
||||
f.Archive()
|
||||
_ = s.store.Update(ctx, f)
|
||||
case memory.OnExpireDelete:
|
||||
_ = s.store.Delete(ctx, f.ID)
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// GetStats returns fact store statistics.
|
||||
func (s *FactService) GetStats(ctx context.Context) (*memory.FactStoreStats, error) {
|
||||
return s.store.Stats(ctx)
|
||||
}
|
||||
|
||||
// GetL0Facts returns L0 facts from cache (fast path) or store.
|
||||
func (s *FactService) GetL0Facts(ctx context.Context) ([]*memory.Fact, error) {
|
||||
if s.cache != nil {
|
||||
facts, err := s.cache.GetL0Facts(ctx)
|
||||
if err == nil && len(facts) > 0 {
|
||||
return facts, nil
|
||||
}
|
||||
}
|
||||
facts, err := s.store.ListByLevel(ctx, memory.LevelProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Warm cache.
|
||||
if s.cache != nil && len(facts) > 0 {
|
||||
_ = s.cache.WarmUp(ctx, facts)
|
||||
}
|
||||
return facts, nil
|
||||
}
|
||||
|
||||
// ToJSON marshals any value to indented JSON string.
|
||||
func ToJSON(v interface{}) string {
|
||||
data, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ListGenes returns all genome facts (immutable survival invariants).
|
||||
func (s *FactService) ListGenes(ctx context.Context) ([]*memory.Fact, error) {
|
||||
return s.store.ListGenes(ctx)
|
||||
}
|
||||
|
||||
// VerifyGenome computes the Merkle hash of all genes and returns integrity status.
|
||||
func (s *FactService) VerifyGenome(ctx context.Context) (string, int, error) {
|
||||
genes, err := s.store.ListGenes(ctx)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("list genes: %w", err)
|
||||
}
|
||||
hash := memory.GenomeHash(genes)
|
||||
return hash, len(genes), nil
|
||||
}
|
||||
|
||||
// Store returns the underlying FactStore for direct access by subsystems
|
||||
// (e.g., apoptosis recovery that needs raw store operations).
|
||||
func (s *FactService) Store() memory.FactStore {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// --- v3.3 Context GC ---
|
||||
|
||||
// GetColdFacts returns facts with hit_count=0, created >30 days ago.
|
||||
// Genes are excluded. Use for memory hygiene review.
|
||||
func (s *FactService) GetColdFacts(ctx context.Context, limit int) ([]*memory.Fact, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return s.store.GetColdFacts(ctx, limit)
|
||||
}
|
||||
|
||||
// CompressFactsParams holds parameters for the compress_facts tool.
|
||||
type CompressFactsParams struct {
|
||||
IDs []string `json:"fact_ids"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// CompressFacts archives the given facts and creates a summary fact.
|
||||
// Genes are silently skipped (invariant protection).
|
||||
func (s *FactService) CompressFacts(ctx context.Context, params CompressFactsParams) (string, error) {
|
||||
if len(params.IDs) == 0 {
|
||||
return "", fmt.Errorf("fact_ids is required")
|
||||
}
|
||||
if params.Summary == "" {
|
||||
return "", fmt.Errorf("summary is required")
|
||||
}
|
||||
// v3.7: auto-backup decision before compression.
|
||||
if s.recorder != nil {
|
||||
s.recorder.RecordDecision("ORACLE", "COMPRESS_FACTS",
|
||||
fmt.Sprintf("ids=%v summary=%s", params.IDs, params.Summary))
|
||||
}
|
||||
return s.store.CompressFacts(ctx, params.IDs, params.Summary)
|
||||
}
|
||||
160
internal/application/tools/fact_service_test.go
Normal file
160
internal/application/tools/fact_service_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/memory"
|
||||
"github.com/sentinel-community/gomcp/internal/infrastructure/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestFactService(t *testing.T) *FactService {
|
||||
t.Helper()
|
||||
db, err := sqlite.OpenMemory()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
repo, err := sqlite.NewFactRepo(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewFactService(repo, nil)
|
||||
}
|
||||
|
||||
func TestFactService_AddFact(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fact, err := svc.AddFact(ctx, AddFactParams{
|
||||
Content: "Go is fast",
|
||||
Level: 0,
|
||||
Domain: "core",
|
||||
Module: "engine",
|
||||
CodeRef: "main.go:42",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fact)
|
||||
|
||||
assert.Equal(t, "Go is fast", fact.Content)
|
||||
assert.Equal(t, memory.LevelProject, fact.Level)
|
||||
assert.Equal(t, "core", fact.Domain)
|
||||
}
|
||||
|
||||
func TestFactService_AddFact_InvalidLevel(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.AddFact(ctx, AddFactParams{Content: "test", Level: 99})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFactService_GetFact(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fact, err := svc.AddFact(ctx, AddFactParams{Content: "test", Level: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := svc.GetFact(ctx, fact.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fact.ID, got.ID)
|
||||
}
|
||||
|
||||
func TestFactService_UpdateFact(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fact, err := svc.AddFact(ctx, AddFactParams{Content: "original", Level: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
newContent := "updated"
|
||||
updated, err := svc.UpdateFact(ctx, UpdateFactParams{
|
||||
ID: fact.ID,
|
||||
Content: &newContent,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated", updated.Content)
|
||||
}
|
||||
|
||||
func TestFactService_DeleteFact(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fact, err := svc.AddFact(ctx, AddFactParams{Content: "delete me", Level: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.DeleteFact(ctx, fact.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.GetFact(ctx, fact.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFactService_ListFacts_ByDomain(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f1", Level: 0, Domain: "backend"})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f2", Level: 1, Domain: "backend"})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f3", Level: 0, Domain: "frontend"})
|
||||
|
||||
facts, err := svc.ListFacts(ctx, ListFactsParams{Domain: "backend"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, facts, 2)
|
||||
}
|
||||
|
||||
func TestFactService_SearchFacts(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "Go concurrency", Level: 0})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "Python is slow", Level: 0})
|
||||
|
||||
results, err := svc.SearchFacts(ctx, "Go", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
}
|
||||
|
||||
func TestFactService_GetStats(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f1", Level: 0, Domain: "core"})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f2", Level: 1, Domain: "core"})
|
||||
|
||||
stats, err := svc.GetStats(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, stats.TotalFacts)
|
||||
}
|
||||
|
||||
func TestFactService_GetL0Facts(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "L0 fact", Level: 0})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "L1 fact", Level: 1})
|
||||
|
||||
facts, err := svc.GetL0Facts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, facts, 1)
|
||||
assert.Equal(t, "L0 fact", facts[0].Content)
|
||||
}
|
||||
|
||||
func TestFactService_ListDomains(t *testing.T) {
|
||||
svc := newTestFactService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f1", Level: 0, Domain: "backend"})
|
||||
_, _ = svc.AddFact(ctx, AddFactParams{Content: "f2", Level: 0, Domain: "frontend"})
|
||||
|
||||
domains, err := svc.ListDomains(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, domains, 2)
|
||||
}
|
||||
|
||||
func TestToJSON(t *testing.T) {
|
||||
result := ToJSON(map[string]string{"key": "value"})
|
||||
assert.Contains(t, result, "\"key\"")
|
||||
assert.Contains(t, result, "\"value\"")
|
||||
}
|
||||
52
internal/application/tools/intent_service.go
Normal file
52
internal/application/tools/intent_service.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Package tools provides application-level tool services.
|
||||
// This file adds the Intent Distiller MCP tool integration (DIP H0.2).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/intent"
|
||||
"github.com/sentinel-community/gomcp/internal/domain/vectorstore"
|
||||
)
|
||||
|
||||
// IntentService provides MCP tool logic for intent distillation.
|
||||
type IntentService struct {
|
||||
distiller *intent.Distiller
|
||||
embedder vectorstore.Embedder
|
||||
}
|
||||
|
||||
// NewIntentService creates a new IntentService.
|
||||
// If embedder is nil, the service will be unavailable.
|
||||
func NewIntentService(embedder vectorstore.Embedder) *IntentService {
|
||||
if embedder == nil {
|
||||
return &IntentService{}
|
||||
}
|
||||
|
||||
embedFn := func(ctx context.Context, text string) ([]float64, error) {
|
||||
return embedder.Embed(ctx, text)
|
||||
}
|
||||
|
||||
return &IntentService{
|
||||
distiller: intent.NewDistiller(embedFn, nil),
|
||||
embedder: embedder,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable returns true if the intent distiller is ready.
|
||||
func (s *IntentService) IsAvailable() bool {
|
||||
return s.distiller != nil && s.embedder != nil
|
||||
}
|
||||
|
||||
// DistillIntentParams holds parameters for the distill_intent tool.
|
||||
type DistillIntentParams struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// DistillIntent performs recursive intent distillation on user text.
|
||||
func (s *IntentService) DistillIntent(ctx context.Context, params DistillIntentParams) (*intent.DistillResult, error) {
|
||||
if !s.IsAvailable() {
|
||||
return nil, fmt.Errorf("intent distiller not available (no embedder configured)")
|
||||
}
|
||||
return s.distiller.Distill(ctx, params.Text)
|
||||
}
|
||||
123
internal/application/tools/pulse.go
Normal file
123
internal/application/tools/pulse.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/memory"
|
||||
)
|
||||
|
||||
// ProjectPulse generates auto-documentation from L0/L1 facts (v3.7 Cerebro).
|
||||
// Extracts facts from memory, groups by domain, and produces a structured
|
||||
// markdown report reflecting the current state of the project.
|
||||
type ProjectPulse struct {
|
||||
facts *FactService
|
||||
}
|
||||
|
||||
// NewProjectPulse creates an auto-documentation generator.
|
||||
func NewProjectPulse(facts *FactService) *ProjectPulse {
|
||||
return &ProjectPulse{facts: facts}
|
||||
}
|
||||
|
||||
// PulseSection is a domain section of the auto-generated documentation.
|
||||
type PulseSection struct {
|
||||
Domain string `json:"domain"`
|
||||
Facts []string `json:"facts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// PulseReport is the full auto-generated documentation.
|
||||
type PulseReport struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
ProjectName string `json:"project_name"`
|
||||
Sections []PulseSection `json:"sections"`
|
||||
TotalFacts int `json:"total_facts"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
// Generate produces a documentation report from L0 (project) and L1 (domain) facts.
|
||||
func (p *ProjectPulse) Generate(ctx context.Context) (*PulseReport, error) {
|
||||
// Get L0 facts (project-level).
|
||||
l0Facts, err := p.facts.GetL0Facts(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulse: L0 facts: %w", err)
|
||||
}
|
||||
|
||||
// Get L1 facts (domain-level) by listing domains.
|
||||
domains, err := p.facts.ListDomains(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulse: list domains: %w", err)
|
||||
}
|
||||
|
||||
report := &PulseReport{
|
||||
GeneratedAt: time.Now(),
|
||||
ProjectName: "GoMCP",
|
||||
}
|
||||
|
||||
// L0 section.
|
||||
if len(l0Facts) > 0 {
|
||||
section := PulseSection{Domain: "Project (L0)", Count: len(l0Facts)}
|
||||
for _, f := range l0Facts {
|
||||
section.Facts = append(section.Facts, factSummary(f))
|
||||
}
|
||||
report.Sections = append(report.Sections, section)
|
||||
report.TotalFacts += len(l0Facts)
|
||||
}
|
||||
|
||||
// L1 sections per domain.
|
||||
for _, domain := range domains {
|
||||
domainFacts, err := p.facts.ListFacts(ctx, ListFactsParams{Domain: domain})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Filter to L1 only.
|
||||
var filtered []*memory.Fact
|
||||
for _, f := range domainFacts {
|
||||
if f.Level <= 1 {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
section := PulseSection{Domain: domain, Count: len(filtered)}
|
||||
for _, f := range filtered {
|
||||
section.Facts = append(section.Facts, factSummary(f))
|
||||
}
|
||||
report.Sections = append(report.Sections, section)
|
||||
report.TotalFacts += len(filtered)
|
||||
}
|
||||
|
||||
report.Markdown = renderPulseMarkdown(report)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func factSummary(f *memory.Fact) string {
|
||||
s := f.Content
|
||||
if len(s) > 120 {
|
||||
s = s[:120] + "..."
|
||||
}
|
||||
label := ""
|
||||
if f.IsGene {
|
||||
label = " 🧬"
|
||||
}
|
||||
return fmt.Sprintf("- %s%s", s, label)
|
||||
}
|
||||
|
||||
func renderPulseMarkdown(r *PulseReport) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s — Project Pulse\n\n", r.ProjectName)
|
||||
fmt.Fprintf(&b, "> Auto-generated: %s | %d facts\n\n", r.GeneratedAt.Format("2006-01-02 15:04"), r.TotalFacts)
|
||||
|
||||
for _, section := range r.Sections {
|
||||
fmt.Fprintf(&b, "## %s (%d facts)\n\n", section.Domain, section.Count)
|
||||
for _, fact := range section.Facts {
|
||||
fmt.Fprintln(&b, fact)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
74
internal/application/tools/session_service.go
Normal file
74
internal/application/tools/session_service.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/session"
|
||||
)
|
||||
|
||||
// SessionService implements MCP tool logic for cognitive state operations.
|
||||
type SessionService struct {
|
||||
store session.StateStore
|
||||
}
|
||||
|
||||
// NewSessionService creates a new SessionService.
|
||||
func NewSessionService(store session.StateStore) *SessionService {
|
||||
return &SessionService{store: store}
|
||||
}
|
||||
|
||||
// SaveStateParams holds parameters for the save_state tool.
|
||||
type SaveStateParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
GoalDesc string `json:"goal_description,omitempty"`
|
||||
Progress float64 `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
// SaveState saves a cognitive state vector.
|
||||
func (s *SessionService) SaveState(ctx context.Context, state *session.CognitiveStateVector) error {
|
||||
checksum := state.Checksum()
|
||||
return s.store.Save(ctx, state, checksum)
|
||||
}
|
||||
|
||||
// LoadState loads the latest (or specific version) of a session state.
|
||||
func (s *SessionService) LoadState(ctx context.Context, sessionID string, version *int) (*session.CognitiveStateVector, string, error) {
|
||||
return s.store.Load(ctx, sessionID, version)
|
||||
}
|
||||
|
||||
// ListSessions returns all persisted sessions.
|
||||
func (s *SessionService) ListSessions(ctx context.Context) ([]session.SessionInfo, error) {
|
||||
return s.store.ListSessions(ctx)
|
||||
}
|
||||
|
||||
// DeleteSession removes all versions of a session.
|
||||
func (s *SessionService) DeleteSession(ctx context.Context, sessionID string) (int, error) {
|
||||
return s.store.DeleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetAuditLog returns the audit log for a session.
|
||||
func (s *SessionService) GetAuditLog(ctx context.Context, sessionID string, limit int) ([]session.AuditEntry, error) {
|
||||
return s.store.GetAuditLog(ctx, sessionID, limit)
|
||||
}
|
||||
|
||||
// RestoreOrCreate loads an existing session or creates a new one.
|
||||
func (s *SessionService) RestoreOrCreate(ctx context.Context, sessionID string) (*session.CognitiveStateVector, bool, error) {
|
||||
state, _, err := s.store.Load(ctx, sessionID, nil)
|
||||
if err == nil {
|
||||
return state, true, nil // restored
|
||||
}
|
||||
// Create new session.
|
||||
newState := session.NewCognitiveStateVector(sessionID)
|
||||
if err := s.SaveState(ctx, newState); err != nil {
|
||||
return nil, false, fmt.Errorf("save new session: %w", err)
|
||||
}
|
||||
return newState, false, nil // created
|
||||
}
|
||||
|
||||
// GetCompactState returns a compact text representation of the current state.
|
||||
func (s *SessionService) GetCompactState(ctx context.Context, sessionID string, maxTokens int) (string, error) {
|
||||
state, _, err := s.store.Load(ctx, sessionID, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return state.ToCompactString(maxTokens), nil
|
||||
}
|
||||
117
internal/application/tools/session_service_test.go
Normal file
117
internal/application/tools/session_service_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/session"
|
||||
"github.com/sentinel-community/gomcp/internal/infrastructure/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestSessionService(t *testing.T) *SessionService {
|
||||
t.Helper()
|
||||
db, err := sqlite.OpenMemory()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
repo, err := sqlite.NewStateRepo(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewSessionService(repo)
|
||||
}
|
||||
|
||||
func TestSessionService_SaveState_LoadState(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state := session.NewCognitiveStateVector("test-session")
|
||||
state.SetGoal("Build GoMCP", 0.3)
|
||||
state.AddFact("Go 1.25", "requirement", 1.0)
|
||||
|
||||
require.NoError(t, svc.SaveState(ctx, state))
|
||||
|
||||
loaded, checksum, err := svc.LoadState(ctx, "test-session", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.NotEmpty(t, checksum)
|
||||
assert.Equal(t, "Build GoMCP", loaded.PrimaryGoal.Description)
|
||||
}
|
||||
|
||||
func TestSessionService_ListSessions(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s1 := session.NewCognitiveStateVector("s1")
|
||||
s2 := session.NewCognitiveStateVector("s2")
|
||||
require.NoError(t, svc.SaveState(ctx, s1))
|
||||
require.NoError(t, svc.SaveState(ctx, s2))
|
||||
|
||||
sessions, err := svc.ListSessions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, sessions, 2)
|
||||
}
|
||||
|
||||
func TestSessionService_DeleteSession(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state := session.NewCognitiveStateVector("to-delete")
|
||||
require.NoError(t, svc.SaveState(ctx, state))
|
||||
|
||||
count, err := svc.DeleteSession(ctx, "to-delete")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestSessionService_RestoreOrCreate_New(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state, restored, err := svc.RestoreOrCreate(ctx, "new-session")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, restored)
|
||||
assert.Equal(t, "new-session", state.SessionID)
|
||||
}
|
||||
|
||||
func TestSessionService_RestoreOrCreate_Existing(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
original := session.NewCognitiveStateVector("existing")
|
||||
original.SetGoal("Saved goal", 0.5)
|
||||
require.NoError(t, svc.SaveState(ctx, original))
|
||||
|
||||
state, restored, err := svc.RestoreOrCreate(ctx, "existing")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, restored)
|
||||
assert.Equal(t, "Saved goal", state.PrimaryGoal.Description)
|
||||
}
|
||||
|
||||
func TestSessionService_GetCompactState(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state := session.NewCognitiveStateVector("compact")
|
||||
state.SetGoal("Test compact", 0.5)
|
||||
state.AddFact("fact1", "requirement", 1.0)
|
||||
require.NoError(t, svc.SaveState(ctx, state))
|
||||
|
||||
compact, err := svc.GetCompactState(ctx, "compact", 500)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, compact, "Test compact")
|
||||
assert.Contains(t, compact, "fact1")
|
||||
}
|
||||
|
||||
func TestSessionService_GetAuditLog(t *testing.T) {
|
||||
svc := newTestSessionService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state := session.NewCognitiveStateVector("audited")
|
||||
require.NoError(t, svc.SaveState(ctx, state))
|
||||
|
||||
log, err := svc.GetAuditLog(ctx, "audited", 10)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(log), 1)
|
||||
}
|
||||
84
internal/application/tools/synapse_service.go
Normal file
84
internal/application/tools/synapse_service.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/synapse"
|
||||
)
|
||||
|
||||
// SynapseService implements MCP tool logic for synapse operations.
|
||||
type SynapseService struct {
|
||||
store synapse.SynapseStore
|
||||
recorder DecisionRecorder // v3.7: tamper-evident trace
|
||||
}
|
||||
|
||||
// NewSynapseService creates a new SynapseService.
|
||||
func NewSynapseService(store synapse.SynapseStore) *SynapseService {
|
||||
return &SynapseService{store: store}
|
||||
}
|
||||
|
||||
// SuggestSynapsesResult contains a pending synapse for architect review.
|
||||
type SuggestSynapsesResult struct {
|
||||
ID int64 `json:"id"`
|
||||
FactIDA string `json:"fact_id_a"`
|
||||
FactIDB string `json:"fact_id_b"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// SuggestSynapses returns pending synapses for architect approval.
|
||||
func (s *SynapseService) SuggestSynapses(ctx context.Context, limit int) ([]SuggestSynapsesResult, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
pending, err := s.store.ListPending(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending: %w", err)
|
||||
}
|
||||
|
||||
results := make([]SuggestSynapsesResult, len(pending))
|
||||
for i, syn := range pending {
|
||||
results[i] = SuggestSynapsesResult{
|
||||
ID: syn.ID,
|
||||
FactIDA: syn.FactIDA,
|
||||
FactIDB: syn.FactIDB,
|
||||
Confidence: syn.Confidence,
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AcceptSynapse transitions a synapse from PENDING to VERIFIED.
|
||||
// Only VERIFIED synapses influence context ranking.
|
||||
func (s *SynapseService) AcceptSynapse(ctx context.Context, id int64) error {
|
||||
err := s.store.Accept(ctx, id)
|
||||
if err == nil && s.recorder != nil {
|
||||
s.recorder.RecordDecision("SYNAPSE", "ACCEPT_SYNAPSE", fmt.Sprintf("synapse_id=%d", id))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RejectSynapse transitions a synapse from PENDING to REJECTED.
|
||||
func (s *SynapseService) RejectSynapse(ctx context.Context, id int64) error {
|
||||
err := s.store.Reject(ctx, id)
|
||||
if err == nil && s.recorder != nil {
|
||||
s.recorder.RecordDecision("SYNAPSE", "REJECT_SYNAPSE", fmt.Sprintf("synapse_id=%d", id))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SynapseStats returns counts by status.
|
||||
type SynapseStats struct {
|
||||
Pending int `json:"pending"`
|
||||
Verified int `json:"verified"`
|
||||
Rejected int `json:"rejected"`
|
||||
}
|
||||
|
||||
// GetStats returns synapse counts.
|
||||
func (s *SynapseService) GetStats(ctx context.Context) (*SynapseStats, error) {
|
||||
p, v, r, err := s.store.Count(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SynapseStats{Pending: p, Verified: v, Rejected: r}, nil
|
||||
}
|
||||
94
internal/application/tools/system_service.go
Normal file
94
internal/application/tools/system_service.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/domain/memory"
|
||||
)
|
||||
|
||||
// Version info set at build time via ldflags.
|
||||
var (
|
||||
Version = "2.0.0-dev"
|
||||
GitCommit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
// SystemService implements MCP tool logic for system operations.
|
||||
type SystemService struct {
|
||||
factStore memory.FactStore
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// NewSystemService creates a new SystemService.
|
||||
func NewSystemService(factStore memory.FactStore) *SystemService {
|
||||
return &SystemService{
|
||||
factStore: factStore,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// HealthStatus holds the health check result.
|
||||
type HealthStatus struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Uptime string `json:"uptime"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
|
||||
// Health returns server health status.
|
||||
func (s *SystemService) Health(_ context.Context) *HealthStatus {
|
||||
return &HealthStatus{
|
||||
Status: "healthy",
|
||||
Version: Version,
|
||||
GoVersion: runtime.Version(),
|
||||
Uptime: time.Since(s.startTime).Round(time.Second).String(),
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
// VersionInfo holds version information.
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
// GetVersion returns version information.
|
||||
func (s *SystemService) GetVersion() *VersionInfo {
|
||||
return &VersionInfo{
|
||||
Version: Version,
|
||||
GitCommit: GitCommit,
|
||||
BuildDate: BuildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
}
|
||||
}
|
||||
|
||||
// DashboardData holds summary data for the system dashboard.
|
||||
type DashboardData struct {
|
||||
Health *HealthStatus `json:"health"`
|
||||
FactStats *memory.FactStoreStats `json:"fact_stats,omitempty"`
|
||||
}
|
||||
|
||||
// Dashboard returns a summary of all system metrics.
|
||||
func (s *SystemService) Dashboard(ctx context.Context) (*DashboardData, error) {
|
||||
data := &DashboardData{
|
||||
Health: s.Health(ctx),
|
||||
}
|
||||
|
||||
if s.factStore != nil {
|
||||
stats, err := s.factStore.Stats(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get fact stats: %w", err)
|
||||
}
|
||||
data.FactStats = stats
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
94
internal/application/tools/system_service_test.go
Normal file
94
internal/application/tools/system_service_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentinel-community/gomcp/internal/infrastructure/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestSystemService(t *testing.T) *SystemService {
|
||||
t.Helper()
|
||||
db, err := sqlite.OpenMemory()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
repo, err := sqlite.NewFactRepo(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewSystemService(repo)
|
||||
}
|
||||
|
||||
func TestSystemService_Health(t *testing.T) {
|
||||
svc := newTestSystemService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
health := svc.Health(ctx)
|
||||
require.NotNil(t, health)
|
||||
assert.Equal(t, "healthy", health.Status)
|
||||
assert.NotEmpty(t, health.GoVersion)
|
||||
assert.NotEmpty(t, health.Version)
|
||||
assert.NotEmpty(t, health.OS)
|
||||
assert.NotEmpty(t, health.Arch)
|
||||
assert.NotEmpty(t, health.Uptime)
|
||||
}
|
||||
|
||||
func TestSystemService_GetVersion(t *testing.T) {
|
||||
svc := newTestSystemService(t)
|
||||
|
||||
ver := svc.GetVersion()
|
||||
require.NotNil(t, ver)
|
||||
assert.NotEmpty(t, ver.Version)
|
||||
assert.NotEmpty(t, ver.GoVersion)
|
||||
assert.Equal(t, Version, ver.Version)
|
||||
assert.Equal(t, GitCommit, ver.GitCommit)
|
||||
assert.Equal(t, BuildDate, ver.BuildDate)
|
||||
}
|
||||
|
||||
func TestSystemService_Dashboard(t *testing.T) {
|
||||
svc := newTestSystemService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
data, err := svc.Dashboard(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, data)
|
||||
assert.NotNil(t, data.Health)
|
||||
assert.Equal(t, "healthy", data.Health.Status)
|
||||
assert.NotNil(t, data.FactStats)
|
||||
assert.Equal(t, 0, data.FactStats.TotalFacts)
|
||||
}
|
||||
|
||||
func TestSystemService_Dashboard_WithFacts(t *testing.T) {
|
||||
svc := newTestSystemService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Add facts through the underlying store.
|
||||
factSvc := NewFactService(svc.factStore, nil)
|
||||
_, _ = factSvc.AddFact(ctx, AddFactParams{Content: "f1", Level: 0, Domain: "core"})
|
||||
_, _ = factSvc.AddFact(ctx, AddFactParams{Content: "f2", Level: 1, Domain: "backend"})
|
||||
|
||||
data, err := svc.Dashboard(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, data.FactStats.TotalFacts)
|
||||
}
|
||||
|
||||
func TestSystemService_Dashboard_NilFactStore(t *testing.T) {
|
||||
svc := &SystemService{factStore: nil}
|
||||
|
||||
data, err := svc.Dashboard(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, data.Health)
|
||||
assert.Nil(t, data.FactStats)
|
||||
}
|
||||
|
||||
func TestSystemService_Uptime(t *testing.T) {
|
||||
svc := newTestSystemService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
h1 := svc.Health(ctx)
|
||||
assert.NotEmpty(t, h1.Uptime)
|
||||
// Uptime should be a parseable duration string like "0s" or "1ms".
|
||||
assert.Contains(t, h1.Uptime, "s")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue