3 Commits

Author SHA1 Message Date
warkanum e459775740 feat: add webhook thought ingestion
CI / build-and-test (pull_request) Successful in 3m33s
CI / build-and-test (push) Successful in 3m37s
2026-07-15 04:27:09 +02:00
warkanum 5718685c40 Merge pull request 'Expose retrieval mode in query responses' (#37) from issue-14-expose-retrieval-mode into main
CI / build-and-test (push) Failing after 57s
Reviewed-on: #37
Reviewed-by: Warky <2+warkanum@noreply@warky.dev>
2026-07-14 14:44:14 +00:00
warkanum cfc78e0493 feat(tools): expose retrieval_mode in query-based tool responses
CI / build-and-test (push) Failing after 1m14s
CI / build-and-test (pull_request) Failing after 2m11s
Add a retrieval_mode field ("semantic" or "text") to the output of all
five query-driven tools so callers can distinguish vector search from
Postgres full-text fallback without inspecting server logs.

Tools updated: search_thoughts, recall_context, get_project_context,
summarize_thoughts, and related_thoughts (semantic neighbours only;
omitempty so field is absent when include_semantic is false or no query
is provided for the non-mandatory-query tools).

The shared semanticSearch helper now returns the mode as a third return
value. All callers updated; fixes two compile errors left by the
previous worker (summarize.go and links.go were not capturing the new
return).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 12:44:53 +02:00
15 changed files with 515 additions and 41 deletions
+14
View File
@@ -34,6 +34,20 @@ jobs:
- name: Tidy modules
run: go mod tidy
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install pnpm
run: npm install -g pnpm
- name: Build UI
run: |
cd ui
pnpm install --frozen-lockfile
pnpm run build
- name: Run tests
run: go test ./...
+33 -1
View File
@@ -74,6 +74,38 @@ The AMCS directory is used to store configuration and code for the Avalon Memory
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
## Webhook ingestion
External automation can create thoughts without speaking MCP by posting JSON to `POST /webhooks/thoughts`. The endpoint is protected by the same AMCS authentication middleware as MCP and file uploads, so pass one configured API key via `x-brain-key`, an authorization bearer token header, or another enabled auth method.
Example:
```bash
curl -X POST http://localhost:8080/webhooks/thoughts \
-H 'Content-Type: application/json' \
-H 'x-brain-key: <api-key>' \
-H 'Idempotency-Key: n8n-run-123' \
-d '{
"content": "External system observed build failure on main",
"project": "amcs",
"source": "n8n",
"type": "task",
"topics": ["ci", "webhook"],
"metadata": {"workflow": "ci-monitor", "run_id": "123"}
}'
```
Payload fields:
- `content` is required and becomes the thought content.
- `project` is optional; when present it must match an existing AMCS project.
- `source`, `type`, `topics`, `people`, `action_items`, and `dates_mentioned` are normalized into the standard thought metadata schema. Unknown `type` values fall back to `observation`.
- `metadata` or `source_metadata` may contain safe source-specific JSON values; unsupported values and overly deep objects are dropped rather than persisted.
- `idempotency_key` or the `Idempotency-Key` header can be supplied to make repeated webhook deliveries return the existing thought with `duplicate: true`.
- `external_id` is stored under `metadata.webhook.external_id` for source-side traceability.
Successful new ingestion returns `201` with the created thought. Duplicate idempotency-key delivery returns `200` and the previously created thought. Invalid JSON, missing content, missing/unknown projects, or unauthenticated requests are rejected before persistence. Metadata and embedding enrichment are queued after the thought is stored.
## Learnings
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
@@ -478,7 +510,7 @@ metadata_retry:
include_archived: false
```
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty.
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. All five tools include a `retrieval_mode` field in their response (`"semantic"` or `"text"`) so callers can see which path was taken.
## Client Setup
+1
View File
@@ -238,6 +238,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
}
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
mux.Handle("/webhooks/thoughts", authMiddleware(newWebhookThoughtHandler(db, embeddings, cfg.Capture, enrichmentRetryer, backfillTool)))
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
+214
View File
@@ -0,0 +1,214 @@
package app
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/metadata"
"git.warky.dev/wdevs/amcs/internal/store"
"git.warky.dev/wdevs/amcs/internal/tools"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const maxWebhookBodyBytes = 1 << 20
type webhookThoughtRequest struct {
Content string `json:"content"`
Project string `json:"project,omitempty"`
Source string `json:"source,omitempty"`
Type string `json:"type,omitempty"`
Topics []string `json:"topics,omitempty"`
People []string `json:"people,omitempty"`
ActionItems []string `json:"action_items,omitempty"`
DatesMentioned []string `json:"dates_mentioned,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
IDempotencyKey string `json:"idempotency_key,omitempty"`
ExternalID string `json:"external_id,omitempty"`
}
type webhookThoughtResponse struct {
Thought thoughttypes.Thought `json:"thought"`
Duplicate bool `json:"duplicate"`
WebhookMeta thoughttypes.WebhookMetadata `json:"webhook"`
}
type webhookThoughtHandler struct {
store *store.DB
embeddings *ai.EmbeddingRunner
capture config.CaptureConfig
retryer tools.MetadataQueuer
embedRetryer tools.EmbeddingQueuer
}
func newWebhookThoughtHandler(db *store.DB, embeddings *ai.EmbeddingRunner, capture config.CaptureConfig, retryer tools.MetadataQueuer, embedRetryer tools.EmbeddingQueuer) http.Handler {
return &webhookThoughtHandler{store: db, embeddings: embeddings, capture: capture, retryer: retryer, embedRetryer: embedRetryer}
}
func (h *webhookThoughtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/webhooks/thoughts" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
in, err := parseWebhookThoughtRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
webhookMeta := buildWebhookMetadata(in, r.Header.Get("Idempotency-Key"), time.Now().UTC())
if webhookMeta.IDempotencyKey != "" {
if existing, err := h.store.GetThoughtByWebhookIDempotencyKey(r.Context(), webhookMeta.IDempotencyKey); err == nil {
writeWebhookThoughtResponse(w, http.StatusOK, webhookThoughtResponse{Thought: existing, Duplicate: true, WebhookMeta: webhookMeta})
return
}
}
projectID, err := h.resolveWebhookProject(r, in.Project)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
thought := thoughttypes.Thought{
Content: strings.TrimSpace(in.Content),
Metadata: normalizeWebhookThoughtMetadata(in, webhookMeta, h.capture),
ProjectID: projectID,
}
created, err := h.store.InsertThought(r.Context(), thought, h.embeddings.PrimaryModel())
if err != nil {
http.Error(w, "insert thought: "+err.Error(), http.StatusInternalServerError)
return
}
if projectID != nil {
_ = h.store.TouchProject(r.Context(), *projectID)
}
if h.retryer != nil {
h.retryer.QueueThought(created.ID)
}
if h.embedRetryer != nil {
h.embedRetryer.QueueThought(r.Context(), created.ID, created.Content)
}
writeWebhookThoughtResponse(w, http.StatusCreated, webhookThoughtResponse{Thought: created, WebhookMeta: webhookMeta})
}
func parseWebhookThoughtRequest(r *http.Request) (webhookThoughtRequest, error) {
if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
return webhookThoughtRequest{}, errors.New("webhook requires application/json")
}
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var in webhookThoughtRequest
if err := decoder.Decode(&in); err != nil {
return webhookThoughtRequest{}, err
}
if strings.TrimSpace(in.Content) == "" {
return webhookThoughtRequest{}, errors.New("content is required")
}
return in, nil
}
func (h *webhookThoughtHandler) resolveWebhookProject(r *http.Request, projectName string) (*int64, error) {
projectName = strings.TrimSpace(projectName)
if projectName == "" {
return nil, nil
}
project, err := h.store.GetProject(r.Context(), projectName)
if err != nil {
return nil, err
}
return &project.NumericID, nil
}
func buildWebhookMetadata(in webhookThoughtRequest, headerKey string, now time.Time) thoughttypes.WebhookMetadata {
sourceMetadata := in.SourceMetadata
if len(sourceMetadata) == 0 {
sourceMetadata = in.Metadata
}
return thoughttypes.WebhookMetadata{
ReceivedAt: now.Format(time.RFC3339),
IDempotencyKey: firstNonEmpty(in.IDempotencyKey, headerKey),
ExternalID: strings.TrimSpace(in.ExternalID),
SourceMetadata: sanitizeWebhookMetadata(sourceMetadata),
}
}
func normalizeWebhookThoughtMetadata(in webhookThoughtRequest, webhookMeta thoughttypes.WebhookMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
return metadata.Normalize(thoughttypes.ThoughtMetadata{
People: in.People,
ActionItems: in.ActionItems,
DatesMentioned: in.DatesMentioned,
Topics: in.Topics,
Type: in.Type,
Source: firstNonEmpty(in.Source, "webhook"),
Webhook: &webhookMeta,
}, capture)
}
func sanitizeWebhookMetadata(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if sanitized, ok := sanitizeWebhookMetadataValue(value, 0); ok {
out[key] = sanitized
}
}
if len(out) == 0 {
return nil
}
return out
}
func sanitizeWebhookMetadataValue(value any, depth int) (any, bool) {
if depth > 3 {
return nil, false
}
switch v := value.(type) {
case nil, bool, float64, string:
return v, true
case []any:
if len(v) > 50 {
v = v[:50]
}
out := make([]any, 0, len(v))
for _, item := range v {
if sanitized, ok := sanitizeWebhookMetadataValue(item, depth+1); ok {
out = append(out, sanitized)
}
}
return out, true
case map[string]any:
if len(v) > 50 {
return nil, false
}
return sanitizeWebhookMetadata(v), true
default:
return nil, false
}
}
func writeWebhookThoughtResponse(w http.ResponseWriter, status int, out webhookThoughtResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(out)
}
+86
View File
@@ -0,0 +1,86 @@
package app
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.warky.dev/wdevs/amcs/internal/config"
)
func TestParseWebhookThoughtRequestRequiresJSON(t *testing.T) {
req := httptestRequest("text/plain", `{"content":"hello"}`)
_, err := parseWebhookThoughtRequest(req)
if err == nil {
t.Fatal("expected error for non-json content type")
}
}
func TestParseWebhookThoughtRequestRequiresContent(t *testing.T) {
req := httptestRequest("application/json", `{"source":"n8n"}`)
_, err := parseWebhookThoughtRequest(req)
if err == nil || !strings.Contains(err.Error(), "content is required") {
t.Fatalf("error = %v, want content required", err)
}
}
func TestBuildWebhookMetadataUsesHeaderIdempotencyAndSanitizesMetadata(t *testing.T) {
now := time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC)
got := buildWebhookMetadata(webhookThoughtRequest{
ExternalID: " ext-1 ",
Metadata: map[string]any{
"service": "n8n",
"unsafe": struct{}{},
"nested": map[string]any{"ok": true},
},
}, " key-1 ", now)
if got.IDempotencyKey != "key-1" {
t.Fatalf("IDempotencyKey = %q, want key-1", got.IDempotencyKey)
}
if got.ExternalID != "ext-1" {
t.Fatalf("ExternalID = %q, want ext-1", got.ExternalID)
}
if got.ReceivedAt != "2026-07-15T04:00:00Z" {
t.Fatalf("ReceivedAt = %q", got.ReceivedAt)
}
if _, ok := got.SourceMetadata["unsafe"]; ok {
t.Fatal("unsafe metadata value was not removed")
}
if got.SourceMetadata["service"] != "n8n" {
t.Fatalf("service metadata = %#v", got.SourceMetadata["service"])
}
}
func TestNormalizeWebhookThoughtMetadata(t *testing.T) {
webhookMeta := buildWebhookMetadata(webhookThoughtRequest{IDempotencyKey: "abc"}, "", time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC))
got := normalizeWebhookThoughtMetadata(webhookThoughtRequest{
Source: "github",
Type: "task",
Topics: []string{"ci", "ci", ""},
People: []string{" Sam "},
}, webhookMeta, config.CaptureConfig{})
if got.Source != "github" {
t.Fatalf("Source = %q, want github", got.Source)
}
if got.Type != "task" {
t.Fatalf("Type = %q, want task", got.Type)
}
if len(got.Topics) != 1 || got.Topics[0] != "ci" {
t.Fatalf("Topics = %#v, want [ci]", got.Topics)
}
if got.Webhook == nil || got.Webhook.IDempotencyKey != "abc" {
t.Fatalf("Webhook = %#v, want idempotency key abc", got.Webhook)
}
}
func httptestRequest(contentType, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/webhooks/thoughts", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
return req
}
+22
View File
@@ -53,6 +53,7 @@ func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) th
Type: normalizeType(in.Type),
Source: normalizeSource(in.Source),
Attachments: normalizeAttachments(in.Attachments),
Webhook: normalizeWebhook(in.Webhook),
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
@@ -201,10 +202,31 @@ func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfi
if len(patch.Attachments) > 0 {
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
}
if patch.Webhook != nil {
merged.Webhook = patch.Webhook
}
return Normalize(merged, capture)
}
func normalizeWebhook(value *thoughttypes.WebhookMetadata) *thoughttypes.WebhookMetadata {
if value == nil {
return nil
}
out := &thoughttypes.WebhookMetadata{
ReceivedAt: strings.TrimSpace(value.ReceivedAt),
IDempotencyKey: strings.TrimSpace(value.IDempotencyKey),
ExternalID: strings.TrimSpace(value.ExternalID),
}
if len(value.SourceMetadata) > 0 {
out.SourceMetadata = value.SourceMetadata
}
if out.ReceivedAt == "" && out.IDempotencyKey == "" && out.ExternalID == "" && len(out.SourceMetadata) == 0 {
return nil
}
return out
}
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
seen := make(map[string]struct{}, len(values))
result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
+16
View File
@@ -68,6 +68,22 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
return created, nil
}
func (db *DB) GetThoughtByWebhookIDempotencyKey(ctx context.Context, key string) (thoughttypes.Thought, error) {
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where metadata->'webhook'->>'idempotency_key' = $1
order by created_at desc
limit 1
`, strings.TrimSpace(key))
var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
return thoughttypes.Thought{}, err
}
return thoughtFromModel(model)
}
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
filterJSON, err := json.Marshal(filter)
if err != nil {
+11 -7
View File
@@ -36,9 +36,10 @@ type ContextItem struct {
}
type ProjectContextOutput struct {
Project thoughttypes.Project `json:"project"`
Context string `json:"context"`
Items []ContextItem `json:"items"`
Project thoughttypes.Project `json:"project"`
Context string `json:"context"`
Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
@@ -70,12 +71,14 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
})
}
var retrievalMode string
query := strings.TrimSpace(in.Query)
if query != "" {
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
if err != nil {
return nil, ProjectContextOutput{}, err
}
retrievalMode = mode
for _, result := range semantic {
key := fmt.Sprint(result.ID)
if _, ok := seen[key]; ok {
@@ -100,8 +103,9 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
_ = t.store.TouchProject(ctx, project.NumericID)
return nil, ProjectContextOutput{
Project: *project,
Context: contextBlock,
Items: items,
Project: *project,
Context: contextBlock,
Items: items,
RetrievalMode: retrievalMode,
}, nil
}
+6 -3
View File
@@ -45,7 +45,8 @@ type RelatedThought struct {
}
type RelatedOutput struct {
Related []RelatedThought `json:"related"`
Related []RelatedThought `json:"related"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
@@ -117,11 +118,13 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
includeSemantic = *in.IncludeSemantic
}
var retrievalMode string
if includeSemantic {
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
if err != nil {
return nil, RelatedOutput{}, err
}
retrievalMode = mode
for _, item := range semantic {
key := fmt.Sprint(item.ID)
if _, ok := seen[key]; ok {
@@ -138,5 +141,5 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
}
}
return nil, RelatedOutput{Related: related}, nil
return nil, RelatedOutput{Related: related, RetrievalMode: retrievalMode}, nil
}
+7 -5
View File
@@ -27,8 +27,9 @@ type RecallInput struct {
}
type RecallOutput struct {
Context string `json:"context"`
Items []ContextItem `json:"items"`
Context string `json:"context"`
Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
@@ -53,7 +54,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
projectID = &project.NumericID
}
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil {
return nil, RecallOutput{}, err
}
@@ -100,7 +101,8 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
}
return nil, RecallOutput{
Context: formatContextBlock(header, lines),
Items: items,
Context: formatContextBlock(header, lines),
Items: items,
RetrievalMode: mode,
}, nil
}
+13 -5
View File
@@ -11,10 +11,16 @@ import (
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const (
RetrievalModeSemantic = "semantic"
RetrievalModeText = "text"
)
// semanticSearch runs vector similarity search if embeddings exist for the
// primary embedding model in the given scope, otherwise falls back to Postgres
// full-text search. Search always uses the primary model so query vectors
// match rows stored under the primary model name.
// It returns the results and the retrieval mode used ("semantic" or "text").
func semanticSearch(
ctx context.Context,
db *store.DB,
@@ -25,20 +31,22 @@ func semanticSearch(
threshold float64,
projectID *int64,
excludeID *uuid.UUID,
) ([]thoughttypes.SearchResult, error) {
) ([]thoughttypes.SearchResult, string, error) {
model := embeddings.PrimaryModel()
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
if err != nil {
return nil, err
return nil, "", err
}
if hasEmbeddings {
embedding, err := embeddings.EmbedPrimary(ctx, query)
if err != nil {
return nil, err
return nil, "", err
}
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
return results, RetrievalModeSemantic, err
}
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
return results, RetrievalModeText, err
}
+60
View File
@@ -0,0 +1,60 @@
package tools
import "testing"
func TestRetrievalModeConstants(t *testing.T) {
if RetrievalModeSemantic != "semantic" {
t.Fatalf("RetrievalModeSemantic = %q, want %q", RetrievalModeSemantic, "semantic")
}
if RetrievalModeText != "text" {
t.Fatalf("RetrievalModeText = %q, want %q", RetrievalModeText, "text")
}
}
func TestSearchOutputIncludesRetrievalMode(t *testing.T) {
out := SearchOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SearchOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
func TestRelatedOutputIncludesRetrievalMode(t *testing.T) {
out := RelatedOutput{RetrievalMode: RetrievalModeText}
if out.RetrievalMode != RetrievalModeText {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeText)
}
// retrieval_mode is omitempty — empty string means semantic search was skipped
out2 := RelatedOutput{}
if out2.RetrievalMode != "" {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want empty when include_semantic is false", out2.RetrievalMode)
}
}
func TestSummarizeOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := SummarizeOutput{Summary: "s", Count: 1, RetrievalMode: RetrievalModeSemantic}
if withQuery.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeSemantic)
}
withoutQuery := SummarizeOutput{Summary: "s", Count: 1}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestProjectContextOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := ProjectContextOutput{RetrievalMode: RetrievalModeText}
if withQuery.RetrievalMode != RetrievalModeText {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeText)
}
withoutQuery := ProjectContextOutput{}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestRecallOutputIncludesRetrievalMode(t *testing.T) {
out := RecallOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("RecallOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
+4 -3
View File
@@ -28,7 +28,8 @@ type SearchInput struct {
}
type SearchOutput struct {
Results []thoughttypes.SearchResult `json:"results"`
Results []thoughttypes.SearchResult `json:"results"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
@@ -55,10 +56,10 @@ func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Se
_ = t.store.TouchProject(ctx, project.NumericID)
}
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
if err != nil {
return nil, SearchOutput{}, err
}
return nil, SearchOutput{Results: results}, nil
return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
}
+7 -4
View File
@@ -28,8 +28,9 @@ type SummarizeInput struct {
}
type SummarizeOutput struct {
Summary string `json:"summary"`
Count int `json:"count"`
Summary string `json:"summary"`
Count int `json:"count"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
@@ -47,15 +48,17 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
lines := make([]string, 0, limit)
count := 0
var retrievalMode string
if query != "" {
var projectID *int64
if project != nil {
projectID = &project.NumericID
}
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil {
return nil, SummarizeOutput{}, err
}
retrievalMode = mode
for i, result := range results {
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
}
@@ -85,5 +88,5 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
_ = t.store.TouchProject(ctx, project.NumericID)
}
return nil, SummarizeOutput{Summary: summary, Count: count}, nil
return nil, SummarizeOutput{Summary: summary, Count: count, RetrievalMode: retrievalMode}, nil
}
+21 -13
View File
@@ -14,12 +14,20 @@ type ThoughtMetadata struct {
Type string `json:"type"`
Source string `json:"source"`
Attachments []ThoughtAttachment `json:"attachments,omitempty"`
Webhook *WebhookMetadata `json:"webhook,omitempty"`
MetadataStatus string `json:"metadata_status,omitempty"`
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
MetadataError string `json:"metadata_error,omitempty"`
}
type WebhookMetadata struct {
ReceivedAt string `json:"received_at"`
IDempotencyKey string `json:"idempotency_key,omitempty"`
ExternalID string `json:"external_id,omitempty"`
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
}
type ThoughtAttachment struct {
FileID uuid.UUID `json:"file_id"`
Name string `json:"name"`
@@ -30,19 +38,19 @@ type ThoughtAttachment struct {
}
type StoredFile struct {
ID int64 `json:"id"`
GUID uuid.UUID `json:"guid"`
ThoughtID *int64 `json:"thought_id,omitempty"`
ProjectID *int64 `json:"project_id,omitempty"`
Name string `json:"name"`
MediaType string `json:"media_type"`
Kind string `json:"kind"`
Encoding string `json:"encoding"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
Content []byte `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
GUID uuid.UUID `json:"guid"`
ThoughtID *int64 `json:"thought_id,omitempty"`
ProjectID *int64 `json:"project_id,omitempty"`
Name string `json:"name"`
MediaType string `json:"media_type"`
Kind string `json:"kind"`
Encoding string `json:"encoding"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
Content []byte `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type StoredFileFilter struct {