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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Publish to NuGet

on:
push:
tags:
- 'v*'

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore
run: dotnet restore src/MemMesh.csproj

- name: Build (gate)
run: dotnet build src/MemMesh.csproj -c Release --no-restore

- name: Pack
run: dotnet pack src/MemMesh.csproj -c Release --no-build -o ./artifacts

- name: Push to NuGet
run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate
56 changes: 56 additions & 0 deletions src/AlertsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace MemMesh;

/// <summary>Alerts — user-defined "tell me when X happens, this way" rules that
/// hook into the engine event stream. Triggers match the same event types
/// <see cref="EventsService.PollAsync"/> returns; channels deliver via HTTP
/// webhook or write the alert back as a memory item for the LLM to pick up on the
/// next context build.
///
/// Mirrors the TS reference surface (<c>tf.alerts.*</c>). Rules live at
/// <c>/memory-alerts</c>; a rule's recent fires at <c>/memory-alerts/{id}/fires</c>.
/// <code>
/// var rule = await mm.Alerts.CreateAsync(new CreateAlertRuleRequest(
/// Name: "VIP at risk",
/// Trigger: new AlertTrigger("engine-event", EventTypes: ["risk.fired"]),
/// Notify: [new NotificationChannel("webhook", Url: "https://hooks.slack.com/...")],
/// Throttle: new ThrottleConfig(DedupOn: "subject", CooldownMinutes: 60)));
/// var fires = await mm.Alerts.ListFiresAsync(rule.Id);
/// </code></summary>
public sealed class AlertsService(MemMeshClient c)
{
/// <summary>List every alert rule in the project.</summary>
public Task<List<AlertRule>> ListAsync(RequestOptions? options = null, CancellationToken ct = default)
=> c.Send<List<AlertRule>>(HttpMethod.Get, "memory-alerts", null, options, ct);

/// <summary>Fetch one alert rule by id.</summary>
public Task<AlertRule> GetAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
=> c.Send<AlertRule>(HttpMethod.Get, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct);

/// <summary>Create an alert rule.</summary>
public Task<AlertRule> CreateAsync(CreateAlertRuleRequest body, RequestOptions? options = null,
CancellationToken ct = default)
=> c.Send<AlertRule>(HttpMethod.Post, "memory-alerts", body, options, ct);

/// <summary>Patch an alert rule — only the fields you set are sent.</summary>
public Task<AlertRule> UpdateAsync(string alertId, UpdateAlertRuleRequest body,
RequestOptions? options = null, CancellationToken ct = default)
=> c.Send<AlertRule>(HttpMethod.Patch, $"memory-alerts/{Uri.EscapeDataString(alertId)}", body, options, ct);

/// <summary>Delete an alert rule.</summary>
public Task DeleteAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
=> c.SendVoid(HttpMethod.Delete, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct);

/// <summary>Convenience — patch only the <c>enabled</c> flag on.</summary>
public Task<AlertRule> EnableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
=> UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: true), options, ct);

/// <summary>Convenience — patch only the <c>enabled</c> flag off.</summary>
public Task<AlertRule> DisableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
=> UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: false), options, ct);

/// <summary>The last ~100 fires for a rule, newest first.</summary>
public Task<List<AlertFire>> ListFiresAsync(string alertId, RequestOptions? options = null,
CancellationToken ct = default)
=> c.Send<List<AlertFire>>(HttpMethod.Get,
$"memory-alerts/{Uri.EscapeDataString(alertId)}/fires", null, options, ct);
}
86 changes: 86 additions & 0 deletions src/BrainsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace MemMesh;

/// <summary>Brains — the marketplace registry. Register, version, and manage the
/// brains a project publishes. A brain carries a Brain Card manifest (ontology,
/// provenance, coverage, eval, pricing) and a stable <c>ExternalId</c> slug the
/// Mesh Router addresses it by.
///
/// Once a brain is <c>PUBLISHED</c> + <c>PUBLIC</c>, any caller can consume it
/// over the hosted MCP endpoint (<c>/brains/{brainId}/mcp-server/http</c>);
/// consumption is an MCP connection, not a REST call, so it lives outside this
/// resource.
///
/// Mirrors the TS reference surface (<c>tf.brains.*</c>): create, the high-level
/// createFromProject path, cursor-paginated list, get, update/version, delete.
/// <code>
/// var brain = await mm.Brains.CreateAsync(new CreateBrainRequest(
/// ExternalId: "sec-edgar-financials", Name: "SEC EDGAR Financials",
/// Domain: "finance", Version: "2026.07.0",
/// Card: new BrainCard(Provenance: [new BrainProvenance("SEC EDGAR", "public-domain")])));
/// await mm.Brains.UpdateAsync(brain.Id,
/// new UpdateBrainRequest(Visibility: "PUBLIC", Status: "PUBLISHED"));
///
/// var page = await mm.Brains.ListAsync(limit: 20);
/// foreach (var b in page.Data) Console.WriteLine($"{b.ExternalId} {b.Status}");
/// </code></summary>
public sealed class BrainsService(MemMeshClient c)
{
/// <summary>Register a new brain in the project's catalog.</summary>
public Task<Brain> CreateAsync(CreateBrainRequest body, RequestOptions? options = null,
CancellationToken ct = default)
=> c.Send<Brain>(HttpMethod.Post, "brains", body, options, ct);

/// <summary>Create a brain from the calling project's memory — the easy,
/// high-level path. Where <see cref="CreateAsync"/> wants a full
/// <see cref="CreateBrainRequest"/>, this builds a sensible one for you from
/// just a slug + name (plus optional domain / version / visibility) and an
/// empty-but-valid Brain Card. Coverage is computed server-side from the
/// project's own memory, so you don't pass it. The brain is created as a
/// <c>DRAFT</c> + <c>PRIVATE</c>; publishing and pricing are deliberate,
/// separate steps.</summary>
public Task<Brain> CreateFromProjectAsync(string externalId, string name,
string? domain = null, string? version = null, string? visibility = null,
RequestOptions? options = null, CancellationToken ct = default)
{
// Empty-but-valid card: an empty provenance list and empty coverage. The
// server recomputes coverage from the project's memory; a real licensed
// provenance source is only required to publish PUBLIC (a separate step).
var body = new CreateBrainRequest(
ExternalId: externalId,
Name: name,
Domain: domain,
Version: version ?? "1.0.0",
Visibility: visibility ?? "PRIVATE",
Card: new BrainCard(Provenance: [], Coverage: new BrainCoverage()));
return CreateAsync(body, options, ct);
}

/// <summary>List the project's brains (cursor-paginated). Pass the returned
/// page's <c>Next</c> cursor back as <paramref name="cursor"/> for the
/// following page; <c>Next</c>/<c>Previous</c> are null at the respective
/// ends. To stream every brain and follow cursors automatically, use
/// <see cref="MemMeshClient.ListAllAsync{T}"/> over the <c>brains</c> path.</summary>
public Task<SeekPage<Brain>> ListAsync(int? limit = null, string? cursor = null,
RequestOptions? options = null, CancellationToken ct = default)
{
var path = "brains" + (limit is not null ? $"?limit={limit}" : "");
return c.GetPageAsync<Brain>(path, cursor, options, ct);
}

/// <summary>Fetch one brain by id. 404s if it doesn't exist or belongs to a
/// different project.</summary>
public Task<Brain> GetAsync(string brainId, RequestOptions? options = null,
CancellationToken ct = default)
=> c.Send<Brain>(HttpMethod.Get, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct);

/// <summary>Update / version a brain (name, version, visibility, status, card,
/// …).</summary>
public Task<Brain> UpdateAsync(string brainId, UpdateBrainRequest body,
RequestOptions? options = null, CancellationToken ct = default)
=> c.Send<Brain>(HttpMethod.Patch, $"brains/{Uri.EscapeDataString(brainId)}", body, options, ct);

/// <summary>Delete a brain from the catalog.</summary>
public Task DeleteAsync(string brainId, RequestOptions? options = null,
CancellationToken ct = default)
=> c.SendVoid(HttpMethod.Delete, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct);
}
166 changes: 166 additions & 0 deletions src/ConsentService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Globalization;
using System.Text.Json;

namespace MemMesh;

/// <summary>Subject-level consent / opt-out.
///
/// Records consent decisions as memory items of <c>type='consent'</c> so the
/// audit log captures every change. The Rust mining engine honors opt-outs at the
/// start of every mine pass — opted-out subjects are skipped, and their behavior
/// patterns aren't generated.
///
/// Foundations of the EU AI Act / GDPR Art. 22 compliance story: subject-level
/// (per-person / per-team / per-workspace) opt-out; audit-traceable (every
/// opt-out is a confirmed memory item); reversible (opt-in re-enables mining,
/// without restoring prior patterns — those must be re-mined to honor the gap).
///
/// Implementation note: this surface has no dedicated endpoints. It is
/// implemented entirely client-side over the admin memory CRUD — consent is
/// written and read as memory items — exactly mirroring the TS
/// <c>ConsentResource</c>. When the engine's dedicated <c>subject_consent</c>
/// table lands, this contract does not change.</summary>
public sealed class ConsentService(MemMeshClient c)
{
/// <summary>Mark a subject as opted-out. Mining and recall must honor this:
/// the engine skips opted-out subjects at mine time.
/// <code>
/// await mm.Consent.OptOutAsync(new Subject("contact", "sarah-pizza"),
/// reason: "GDPR Art. 17 request 2026-05-25");
/// </code></summary>
public async Task<ConsentStatus> OptOutAsync(Subject subject, string? reason = null,
RequestOptions? options = null, CancellationToken ct = default)
{
// Supersede any prior consent record so the audit log shows the history
// but only one row is "active".
await SupersedePriorConsentAsync(subject, options, ct).ConfigureAwait(false);

var now = IsoNow();
var memory = await CreateConsentMemoryAsync(
$"[consent] {subject.Kind}:{subject.ExternalId} opted out",
new Dictionary<string, object?>
{
["subject"] = subject,
["optedOut"] = true,
["optedOutAt"] = now,
["reason"] = reason,
["recordKind"] = "consent",
}, options, ct).ConfigureAwait(false);

return new ConsentStatus(subject, OptedOut: true, OptedOutAt: now, Reason: reason, MemoryId: memory.Id);
}

/// <summary>Restore consent for a subject. Mining resumes from the next pass.
/// Prior patterns are NOT auto-restored — they must be re-mined so the gap
/// during opt-out is honored.</summary>
public async Task<ConsentStatus> OptInAsync(Subject subject, RequestOptions? options = null,
CancellationToken ct = default)
{
await SupersedePriorConsentAsync(subject, options, ct).ConfigureAwait(false);

var now = IsoNow();
var memory = await CreateConsentMemoryAsync(
$"[consent] {subject.Kind}:{subject.ExternalId} opted in",
new Dictionary<string, object?>
{
["subject"] = subject,
["optedOut"] = false,
["optedOutAt"] = null,
["reason"] = null,
["recordKind"] = "consent",
}, options, ct).ConfigureAwait(false);

return new ConsentStatus(subject, OptedOut: false, OptedOutAt: null, Reason: null, MemoryId: memory.Id);
}

/// <summary>Read the current consent status for a subject. Returns
/// <c>OptedOut = false</c> (default) if no consent record exists.</summary>
public async Task<ConsentStatus> GetStatusAsync(Subject subject, RequestOptions? options = null,
CancellationToken ct = default)
{
var active = await FindActiveConsentAsync(subject, options, ct).ConfigureAwait(false);
if (active is null)
return new ConsentStatus(subject, OptedOut: false, OptedOutAt: null, Reason: null, MemoryId: null);

var md = active.Metadata;
return new ConsentStatus(
subject,
OptedOut: ReadBool(md, "optedOut"),
OptedOutAt: ReadString(md, "optedOutAt"),
Reason: ReadString(md, "reason"),
MemoryId: active.Id);
}

// ── private helpers ──────────────────────────────────────────────────────

// Mirror the TS createMemory closure: spread content/type/scope/importance/
// category/metadata onto the admin memory create body.
private Task<MemoryItem> CreateConsentMemoryAsync(string content,
IDictionary<string, object?> metadata, RequestOptions? options, CancellationToken ct)
{
var body = new Dictionary<string, object?>
{
["content"] = content,
["type"] = "consent",
["scope"] = "project",
["importance"] = 10,
["category"] = "consent",
["metadata"] = metadata,
};
return c.Send<MemoryItem>(HttpMethod.Post, "admin/memory", body, options, ct);
}

private async Task<MemoryItem?> FindActiveConsentAsync(Subject subject,
RequestOptions? options, CancellationToken ct)
{
// The engine caps `limit` at 500 (querystring/limit must be <= 500); 1000
// hard-fails every consent lookup.
var all = await c.Send<List<MemoryItem>>(HttpMethod.Get, "admin/memory?limit=500",
null, options, ct).ConfigureAwait(false);

return all
.Where(m => m.Type == "consent" && SubjectMatches(m, subject))
.OrderByDescending(m => ParseCreated(m.Created))
.FirstOrDefault();
}

private async Task SupersedePriorConsentAsync(Subject subject, RequestOptions? options,
CancellationToken ct)
{
var prior = await FindActiveConsentAsync(subject, options, ct).ConfigureAwait(false);
if (prior is not null)
// Hard-delete the prior row. The audit log keeps the historical trail;
// we don't need the old row in the active set anymore.
await c.SendVoid(HttpMethod.Delete, $"admin/memory/{prior.Id}", null, options, ct)
.ConfigureAwait(false);
}

private static bool SubjectMatches(MemoryItem item, Subject subject)
{
if (item.Metadata is null ||
!item.Metadata.TryGetValue("subject", out var s) ||
s.ValueKind != JsonValueKind.Object)
return false;
var kind = s.TryGetProperty("kind", out var k) && k.ValueKind == JsonValueKind.String
? k.GetString() : null;
var externalId = s.TryGetProperty("externalId", out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() : null;
return kind == subject.Kind && externalId == subject.ExternalId;
}

// toISOString() parity: millisecond precision, UTC 'Z' suffix.
private static string IsoNow() =>
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);

private static DateTimeOffset ParseCreated(string? created) =>
DateTimeOffset.TryParse(created, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)
? dt : DateTimeOffset.MinValue;

private static bool ReadBool(IReadOnlyDictionary<string, JsonElement>? md, string key) =>
md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.True;

private static string? ReadString(IReadOnlyDictionary<string, JsonElement>? md, string key) =>
md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
}
Loading