|
|
|
|
@@ -149,7 +149,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|
|
|
|
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)",
|
|
|
|
|
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
|
|
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
|
|
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
|
|
args := argsMap(req)
|
|
|
|
|
@@ -168,7 +168,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|
|
|
|
LIMIT 20`, q), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity",
|
|
|
|
|
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
|
|
|
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
|
|
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
|
|
args := argsMap(req)
|
|
|
|
|
@@ -196,7 +196,19 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|
|
|
|
ORDER BY 1`, slug), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.",
|
|
|
|
|
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
|
|
|
|
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
|
|
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
|
|
args := argsMap(req)
|
|
|
|
|
slug, _ := args["slug"].(string)
|
|
|
|
|
return queryRows(ctx, pool, `
|
|
|
|
|
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
|
|
|
|
FROM knowledge_entities ke
|
|
|
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
|
|
|
WHERE e.slug = $1`, slug), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
|
|
|
|
InputSchema: objSchema(
|
|
|
|
|
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
|
|
|
|
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
|
|
|
|
@@ -428,6 +440,23 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|
|
|
|
}
|
|
|
|
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
|
|
|
|
id, targetID, action+":"+params, correlationID, agentID)
|
|
|
|
|
pool.Exec(ctx, `
|
|
|
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
|
|
|
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
|
|
|
|
WHERE NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM relationships
|
|
|
|
|
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
|
|
|
|
id, targetID)
|
|
|
|
|
if sessionID != "" {
|
|
|
|
|
pool.Exec(ctx, `
|
|
|
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
|
|
|
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
|
|
|
|
FROM entities t WHERE t.slug = $2
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM relationships
|
|
|
|
|
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
|
|
|
|
id, "task:"+sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Execute reversible actions immediately. restart/pct_exec/systemctl
|
|
|
|
|
// (outside enable/disable) never reach here — they're routed through
|
|
|
|
|
@@ -1397,6 +1426,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|
|
|
|
}
|
|
|
|
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
|
|
|
|
id, targetID, actionCol, riskClass, correlationID, agentID)
|
|
|
|
|
pool.Exec(ctx, `
|
|
|
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
|
|
|
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
|
|
|
|
WHERE NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM relationships
|
|
|
|
|
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
|
|
|
|
id, targetID)
|
|
|
|
|
if sessionID != "" {
|
|
|
|
|
pool.Exec(ctx, `
|
|
|
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
|
|
|
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
|
|
|
|
FROM entities t WHERE t.slug = $2
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM relationships
|
|
|
|
|
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
|
|
|
|
id, "task:"+sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if riskClass == policy.RiskReadOnly {
|
|
|
|
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
|
|
|
|
|