diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..f55d4f6
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -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
diff --git a/src/AlertsService.cs b/src/AlertsService.cs
new file mode 100644
index 0000000..efab60e
--- /dev/null
+++ b/src/AlertsService.cs
@@ -0,0 +1,56 @@
+namespace MemMesh;
+
+/// Alerts — user-defined "tell me when X happens, this way" rules that
+/// hook into the engine event stream. Triggers match the same event types
+/// 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 (tf.alerts.*). Rules live at
+/// /memory-alerts; a rule's recent fires at /memory-alerts/{id}/fires.
+///
+/// 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);
+///
+public sealed class AlertsService(MemMeshClient c)
+{
+ /// List every alert rule in the project.
+ public Task> ListAsync(RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send>(HttpMethod.Get, "memory-alerts", null, options, ct);
+
+ /// Fetch one alert rule by id.
+ public Task GetAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Get, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct);
+
+ /// Create an alert rule.
+ public Task CreateAsync(CreateAlertRuleRequest body, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "memory-alerts", body, options, ct);
+
+ /// Patch an alert rule — only the fields you set are sent.
+ public Task UpdateAsync(string alertId, UpdateAlertRuleRequest body,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Patch, $"memory-alerts/{Uri.EscapeDataString(alertId)}", body, options, ct);
+
+ /// Delete an alert rule.
+ public Task DeleteAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
+ => c.SendVoid(HttpMethod.Delete, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct);
+
+ /// Convenience — patch only the enabled flag on.
+ public Task EnableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
+ => UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: true), options, ct);
+
+ /// Convenience — patch only the enabled flag off.
+ public Task DisableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default)
+ => UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: false), options, ct);
+
+ /// The last ~100 fires for a rule, newest first.
+ public Task> ListFiresAsync(string alertId, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send>(HttpMethod.Get,
+ $"memory-alerts/{Uri.EscapeDataString(alertId)}/fires", null, options, ct);
+}
diff --git a/src/BrainsService.cs b/src/BrainsService.cs
new file mode 100644
index 0000000..a27e83c
--- /dev/null
+++ b/src/BrainsService.cs
@@ -0,0 +1,86 @@
+namespace MemMesh;
+
+/// 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 ExternalId slug the
+/// Mesh Router addresses it by.
+///
+/// Once a brain is PUBLISHED + PUBLIC, any caller can consume it
+/// over the hosted MCP endpoint (/brains/{brainId}/mcp-server/http);
+/// consumption is an MCP connection, not a REST call, so it lives outside this
+/// resource.
+///
+/// Mirrors the TS reference surface (tf.brains.*): create, the high-level
+/// createFromProject path, cursor-paginated list, get, update/version, delete.
+///
+/// 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}");
+///
+public sealed class BrainsService(MemMeshClient c)
+{
+ /// Register a new brain in the project's catalog.
+ public Task CreateAsync(CreateBrainRequest body, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "brains", body, options, ct);
+
+ /// Create a brain from the calling project's memory — the easy,
+ /// high-level path. Where wants a full
+ /// , 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
+ /// DRAFT + PRIVATE; publishing and pricing are deliberate,
+ /// separate steps.
+ public Task 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);
+ }
+
+ /// List the project's brains (cursor-paginated). Pass the returned
+ /// page's Next cursor back as for the
+ /// following page; Next/Previous are null at the respective
+ /// ends. To stream every brain and follow cursors automatically, use
+ /// over the brains path.
+ public Task> 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(path, cursor, options, ct);
+ }
+
+ /// Fetch one brain by id. 404s if it doesn't exist or belongs to a
+ /// different project.
+ public Task GetAsync(string brainId, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Get, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct);
+
+ /// Update / version a brain (name, version, visibility, status, card,
+ /// …).
+ public Task UpdateAsync(string brainId, UpdateBrainRequest body,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Patch, $"brains/{Uri.EscapeDataString(brainId)}", body, options, ct);
+
+ /// Delete a brain from the catalog.
+ public Task DeleteAsync(string brainId, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.SendVoid(HttpMethod.Delete, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct);
+}
diff --git a/src/ConsentService.cs b/src/ConsentService.cs
new file mode 100644
index 0000000..5440644
--- /dev/null
+++ b/src/ConsentService.cs
@@ -0,0 +1,166 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace MemMesh;
+
+/// Subject-level consent / opt-out.
+///
+/// Records consent decisions as memory items of type='consent' 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
+/// ConsentResource. When the engine's dedicated subject_consent
+/// table lands, this contract does not change.
+public sealed class ConsentService(MemMeshClient c)
+{
+ /// Mark a subject as opted-out. Mining and recall must honor this:
+ /// the engine skips opted-out subjects at mine time.
+ ///
+ /// await mm.Consent.OptOutAsync(new Subject("contact", "sarah-pizza"),
+ /// reason: "GDPR Art. 17 request 2026-05-25");
+ ///
+ public async Task 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
+ {
+ ["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);
+ }
+
+ /// 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.
+ public async Task 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
+ {
+ ["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);
+ }
+
+ /// Read the current consent status for a subject. Returns
+ /// OptedOut = false (default) if no consent record exists.
+ public async Task 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 CreateConsentMemoryAsync(string content,
+ IDictionary metadata, RequestOptions? options, CancellationToken ct)
+ {
+ var body = new Dictionary
+ {
+ ["content"] = content,
+ ["type"] = "consent",
+ ["scope"] = "project",
+ ["importance"] = 10,
+ ["category"] = "consent",
+ ["metadata"] = metadata,
+ };
+ return c.Send(HttpMethod.Post, "admin/memory", body, options, ct);
+ }
+
+ private async Task 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>(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? md, string key) =>
+ md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.True;
+
+ private static string? ReadString(IReadOnlyDictionary? md, string key) =>
+ md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() : null;
+}
diff --git a/src/Errors.cs b/src/Errors.cs
new file mode 100644
index 0000000..d13ce60
--- /dev/null
+++ b/src/Errors.cs
@@ -0,0 +1,65 @@
+namespace MemMesh;
+
+/// Base type for every error the MemMesh API surfaces. Catch this to
+/// handle any failure uniformly; catch a subclass to branch on the HTTP status.
+/// Status is the HTTP status (0 for transport-level failures),
+/// Code the machine-readable error code, and Params any structured
+/// validation detail the server returned.
+public class MemMeshException : Exception
+{
+ public int Status { get; }
+ public string Body { get; }
+ public string Code { get; }
+ public IReadOnlyDictionary? Params { get; }
+
+ public MemMeshException(int status, string body, string code = "UNKNOWN",
+ IReadOnlyDictionary? @params = null)
+ : base($"memmesh: {status} {body}")
+ {
+ Status = status;
+ Body = body;
+ Code = code;
+ Params = @params;
+ }
+}
+
+/// 401 — invalid or missing API key.
+public sealed class AuthenticationException(string body = "Invalid or missing API key")
+ : MemMeshException(401, body, "AUTHENTICATION_ERROR");
+
+/// 403 — the key is valid but lacks permission for this resource.
+public sealed class AuthorizationException(string body = "Insufficient permissions")
+ : MemMeshException(403, body, "AUTHORIZATION_ERROR");
+
+/// 404 — resource not found.
+public sealed class NotFoundException(string body = "Resource not found")
+ : MemMeshException(404, body, "NOT_FOUND");
+
+/// 400 / 422 — the request failed validation. Params carries the
+/// per-field detail when the server supplies it.
+public sealed class ValidationException(int status, string body = "Validation failed",
+ IReadOnlyDictionary? @params = null)
+ : MemMeshException(status, body, "VALIDATION_ERROR", @params);
+
+/// 429 — rate limited. RetryAfter is the server-advised wait when
+/// a Retry-After header was present; the client already honors it on retry.
+public sealed class RateLimitException(string body = "Rate limit exceeded", TimeSpan? retryAfter = null)
+ : MemMeshException(429, body, "RATE_LIMIT")
+{
+ public TimeSpan? RetryAfter { get; } = retryAfter;
+}
+
+/// 5xx — the server failed to fulfill an otherwise valid request. Retried
+/// with backoff before it surfaces.
+public sealed class ServerException(int status, string body = "Internal server error")
+ : MemMeshException(status, body, "SERVER_ERROR");
+
+/// The request exceeded its timeout (default 30s, per-call overridable).
+/// Not retried — a slow endpoint is unlikely to be faster on an immediate retry.
+public sealed class RequestTimeoutException(string body = "Request timed out")
+ : MemMeshException(0, body, "TIMEOUT");
+
+/// A transport-level failure (DNS, connection reset, TLS) with no HTTP
+/// response. Retried with backoff before it surfaces.
+public sealed class NetworkException(string body = "Network error")
+ : MemMeshException(0, body, "NETWORK_ERROR");
diff --git a/src/EventsService.cs b/src/EventsService.cs
new file mode 100644
index 0000000..8c94d42
--- /dev/null
+++ b/src/EventsService.cs
@@ -0,0 +1,108 @@
+namespace MemMesh;
+
+/// Events — the durable memory event log. The engine emits events on
+/// interesting state changes (pattern emergence, risk firing, segment shift,
+/// consent change); consumers walk the log with (reads
+/// /memory-events) or subscribe to a background poll loop with
+/// . is the write side, appending
+/// to the log (/lattice/events/emit) and firing matching alert rules
+/// synchronously.
+///
+/// Mirrors the TS reference surface (tf.events.*).
+///
+/// using var sub = mm.Events.Subscribe(async e =>
+/// Console.WriteLine($"[{e.Severity}] {e.EventType}"),
+/// eventTypes: ["risk.fired", "segment.changed"]);
+/// // ... later: sub.Dispose() stops the loop.
+///
+public sealed class EventsService(MemMeshClient c)
+{
+ /// Pull events newer than (an ISO timestamp;
+ /// use the last event's OccurredAt as the next cursor).
+ /// defaults to 100, max 1000; restricts to specific
+ /// types.
+ public async Task> PollAsync(string? since = null, int? limit = null,
+ IEnumerable? eventTypes = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new List();
+ if (since is not null) q.Add($"since={Uri.EscapeDataString(since)}");
+ if (limit is not null) q.Add($"limit={limit}");
+ var types = eventTypes?.ToList();
+ if (types is { Count: > 0 }) q.Add($"eventTypes={Uri.EscapeDataString(string.Join(",", types))}");
+ var path = "memory-events" + (q.Count > 0 ? "?" + string.Join("&", q) : "");
+ return await c.Send>(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false);
+ }
+
+ /// Append an event to the durable log. Matching alert rules fire
+ /// synchronously; the write-side counterpart to .
+ public Task EmitAsync(EmitEventRequest body, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "lattice/events/emit", body, options, ct);
+
+ /// Convenience: poll in the background and invoke
+ /// for each event, threading the cursor forward automatically. Handler exceptions
+ /// and transient network errors are swallowed so the loop survives. The interval
+ /// defaults to 5s and is floored at 500ms. Returns an
+ /// — Dispose() it to stop the loop.
+ public EventSubscription Subscribe(Func handler, string? since = null,
+ int? limit = null, IEnumerable? eventTypes = null, TimeSpan? interval = null,
+ RequestOptions? options = null)
+ {
+ var ms = interval?.TotalMilliseconds ?? 5000;
+ var effective = TimeSpan.FromMilliseconds(Math.Max(500, ms));
+ return new EventSubscription(this, handler, since, limit, eventTypes?.ToList(), effective, options);
+ }
+}
+
+/// A running background poll loop started by
+/// . Owns a
+/// and a background ; cancels the loop (idempotent).
+/// Await after disposing to join the loop — mainly for tests.
+public sealed class EventSubscription : IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _loop;
+
+ internal EventSubscription(EventsService events, Func handler, string? since,
+ int? limit, IReadOnlyList? eventTypes, TimeSpan interval, RequestOptions? options)
+ => _loop = RunAsync(events, handler, since, limit, eventTypes, interval, options);
+
+ private async Task RunAsync(EventsService events, Func handler, string? cursor,
+ int? limit, IReadOnlyList? eventTypes, TimeSpan interval, RequestOptions? options)
+ {
+ var ct = _cts.Token;
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var batch = await events.PollAsync(cursor, limit, eventTypes, options, ct).ConfigureAwait(false);
+ foreach (var e in batch)
+ {
+ if (ct.IsCancellationRequested) break;
+ try { await handler(e).ConfigureAwait(false); }
+ catch { /* handler errors are the caller's concern; don't kill the loop */ }
+ cursor = e.OccurredAt;
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch { /* network blip — back off briefly and retry */ }
+
+ try { await Task.Delay(interval, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+ finally { _cts.Dispose(); }
+ }
+
+ /// Completes once the background poll loop has stopped.
+ public Task Completion => _loop;
+
+ /// Stop polling. Idempotent.
+ public void Dispose()
+ {
+ try { _cts.Cancel(); }
+ catch (ObjectDisposedException) { /* already stopped */ }
+ }
+}
diff --git a/src/FinancialService.cs b/src/FinancialService.cs
new file mode 100644
index 0000000..4db75c0
--- /dev/null
+++ b/src/FinancialService.cs
@@ -0,0 +1,132 @@
+using System.Globalization;
+
+namespace MemMesh;
+
+/// Financial — technical indicators, portfolio risk, and a
+/// self-calibrating directional prediction loop for the engine's financial
+/// vertical. Financial data IS memory data: ingest price bars, fundamentals,
+/// holdings, and news as fact memories (stored via /admin/memory) and the
+/// engine derives indicators, risk, and buy/sell/hold calls whose reported
+/// confidence is structural agreement × realized hit-rate. Score due calls with
+/// and inspect honesty with
+/// at /lattice/financial/*.
+///
+/// Requires the @thinkfleet/pack-financial pack; the read methods return
+/// FAILED_PRECONDITION otherwise (ingestion works regardless — it's plain
+/// memory). Mirrors the TS reference surface (tf.financial.*).
+/// Informational only — NOT investment advice.
+///
+/// Market data (prices / fundamentals / news) is pooled across the project;
+/// holdings are private to their subject. A subject is caller-asserted —
+/// the engine does not verify ownership, so only ever pass a subject the current
+/// user is entitled to.
+public sealed class FinancialService(MemMeshClient c)
+{
+ // ── Input — ingest market data + positions (stored as fact memories) ──
+
+ /// Ingest a single price bar. Market data — not subject-attributed.
+ public Task IngestPriceAsync(PriceInput price, RequestOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var content = $"{price.Ticker} close {price.Close.ToString(CultureInfo.InvariantCulture)}"
+ + (price.AsOf is not null ? $" @ {price.AsOf}" : "");
+ return RecordAsync(content, new Dictionary { ["price"] = price }, options, ct);
+ }
+
+ /// Ingest many price bars (e.g. a backfill). Issued concurrently;
+ /// resolves once all are stored. For very large histories, batch yourself.
+ public async Task> IngestPricesAsync(IEnumerable prices,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var results = await Task.WhenAll(prices.Select(p => IngestPriceAsync(p, options, ct)))
+ .ConfigureAwait(false);
+ return results.ToList();
+ }
+
+ /// Ingest/refresh a ticker's fundamentals. Latest values win.
+ public Task IngestFundamentalsAsync(FundamentalInput fundamental,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => RecordAsync($"Fundamentals {fundamental.Ticker}",
+ new Dictionary { ["fundamental"] = fundamental }, options, ct);
+
+ /// Record a portfolio position. Subject-private — attributed to the
+ /// owner (use a { kind: "portfolio", externalId } subject). Restated,
+ /// not summed: re-recording a ticker replaces the prior position.
+ public Task IngestHoldingAsync(Subject subject, HoldingInput holding,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => RecordAsync($"Holding {holding.Shares.ToString(CultureInfo.InvariantCulture)} {holding.Ticker}",
+ new Dictionary { ["subject"] = subject, ["holding"] = holding }, options, ct);
+
+ /// Ingest a news event. Market data — tag one or many tickers.
+ public Task IngestNewsAsync(NewsInput news, RequestOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var label = news.Ticker
+ ?? (news.Tickers is { Count: > 0 } t ? string.Join(",", t) : null)
+ ?? "news";
+ return RecordAsync($"News [{label}]: {news.Headline}",
+ new Dictionary { ["newsEvent"] = news }, options, ct);
+ }
+
+ // ── Read — indicators, risk, calibrated predictions ──
+
+ /// Technical indicators + (for a portfolio subject) a risk rollup,
+ /// derived from ingested market data and holdings. Read-only and forecast-free.
+ /// subject.kind == "ticker" → single-name analysis (externalId is the
+ /// ticker); any other kind → portfolio mode over the subject's holdings.
+ public Task GetProfileAsync(Subject subject, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "lattice/financial/profile",
+ new Dictionary { ["subject"] = subject }, options, ct);
+
+ /// Generate directional buy/sell/hold calls. Reported confidence =
+ /// structural agreement × the strategy's realized reliability. By default each
+ /// call is persisted so it can be scored at horizon by
+ /// . defaults to 30
+ /// (clamped [1, 365]); defaults to true.
+ public Task PredictAsync(Subject subject, int? horizonDays = null,
+ bool? persist = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["subject"] = subject };
+ if (horizonDays is not null) body["horizonDays"] = horizonDays;
+ if (persist is not null) body["persist"] = persist;
+ return c.Send(HttpMethod.Post, "lattice/financial/predict", body, options, ct);
+ }
+
+ /// Run the feedback loop: score every persisted prediction whose horizon
+ /// has elapsed against the realized close, and mark it resolved. Recomputes
+ /// reliability and calibration from these outcomes. Idempotent; safe to run on a
+ /// schedule.
+ public Task ReconcileAsync(RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "lattice/financial/reconcile",
+ new Dictionary(), options, ct);
+
+ /// The honesty proof: resolved predictions bucketed by the confidence we
+ /// reported, with the realized hit-rate per band.
+ /// defaults to 5 (clamped [1, 20]); pass to filter to
+ /// one strategy, omit for all.
+ public Task GetCalibrationAsync(int? bucketCount = null,
+ string? strategy = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary();
+ if (bucketCount is not null) body["bucketCount"] = bucketCount;
+ if (strategy is not null) body["strategy"] = strategy;
+ return c.Send(HttpMethod.Post, "lattice/financial/calibration",
+ body, options, ct);
+ }
+
+ // Financial signals are plain fact memories (category "financial", source
+ // "sdk:financial"), so ingestion works even without the financial pack enabled.
+ private Task RecordAsync(string content, Dictionary metadata,
+ RequestOptions? options, CancellationToken ct)
+ => c.Send(HttpMethod.Post, "admin/memory", new Dictionary
+ {
+ ["content"] = content,
+ ["type"] = "fact",
+ ["scope"] = "project",
+ ["category"] = "financial",
+ ["source"] = "sdk:financial",
+ ["metadata"] = metadata,
+ }, options, ct);
+}
diff --git a/src/GraphService.cs b/src/GraphService.cs
new file mode 100644
index 0000000..6ec19eb
--- /dev/null
+++ b/src/GraphService.cs
@@ -0,0 +1,109 @@
+namespace MemMesh;
+
+/// The knowledge graph built from observed memory — the structural
+/// half of what MemMesh stores.
+///
+/// Observing text doesn't only produce embeddable rows; extraction also
+/// resolves entities and writes typed edges between them. That graph is what
+/// reaches a fact no single memory states outright ("who does Sarah report
+/// to?" answered from sarah -[member_of]-> team plus
+/// team -[led_by]-> priya).
+///
+/// Every route here is admin-tier (/admin/memory/...); a project-scoped
+/// key gets a 403.
+///
+/// Read-only by design. Entities and edges are written by extraction when you
+/// ; the server's manual create/retire
+/// routes exist for annotation tooling, and exposing them here would invite
+/// hand-maintained graphs — the work the engine exists to do for you.
+///
+/// var st = await mm.Graph.StatsAsync();
+/// Console.WriteLine($"{st.EntityCount} entities, {st.EdgeCount} edges");
+///
+/// var ents = await mm.Graph.ListEntitiesAsync(search: "Sarah", limit: 1);
+/// var chain = await mm.Graph.TraverseAsync(ents[0].Id, hops: 2,
+/// predicates: ["member_of", "led_by"]);
+///
+public sealed class GraphService(MemMeshClient c)
+{
+ /// Aggregate counts for the whole graph.
+ ///
+ /// Prefer this over (await ListEntitiesAsync()).Count for any "how
+ /// big is it" question: these are SQL COUNT(*)s over the full table,
+ /// where the list routes page and would report the page size as the
+ /// total.
+ public Task StatsAsync(RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Get, "admin/memory/graph/stats", null, options, ct);
+
+ /// Entities, filtered by type/scope or a substring of name or
+ /// alias. Unset filters are omitted from the query string.
+ public Task> ListEntitiesAsync(string? type = null, string? scope = null,
+ string? search = null, int? limit = null, int? offset = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new QueryString()
+ .Add("type", type).Add("scope", scope).Add("search", search)
+ .Add("limit", limit).Add("offset", offset);
+ return c.Send>(HttpMethod.Get, $"admin/memory/entities{q}", null, options, ct);
+ }
+
+ /// One entity plus its 1-hop neighbourhood.
+ public Task GetEntityAsync(string entityId, string? asOf = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new QueryString().Add("asOf", asOf);
+ return c.Send(HttpMethod.Get, $"admin/memory/entities/{entityId}{q}",
+ null, options, ct);
+ }
+
+ /// Every currently-valid edge. Use for rendering a whole small
+ /// graph; for a large one, seed from an entity and
+ /// instead.
+ public Task> ListEdgesAsync(string? asOf = null, int? limit = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new QueryString().Add("asOf", asOf).Add("limit", limit);
+ return c.Send>(HttpMethod.Get, $"admin/memory/graph/edges{q}",
+ null, options, ct);
+ }
+
+ /// Walk out from a seed entity (1-3 hops).
+ ///
+ /// This is the multi-hop path: the edges returned here connect facts no
+ /// single memory states together, which is how a question gets answered
+ /// from a chain rather than from one lucky vector hit.
+ public Task> TraverseAsync(string entityId, int? hops = null,
+ IEnumerable? predicates = null, string? asOf = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["entityId"] = entityId };
+ if (hops is not null) body["hops"] = hops;
+ if (predicates is not null) body["predicates"] = predicates;
+ if (asOf is not null) body["asOf"] = asOf;
+ return c.Send>(HttpMethod.Post, "admin/memory/graph/traverse",
+ body, options, ct);
+ }
+}
+
+/// Builds a query string, skipping unset values and percent-encoding
+/// the rest.
+///
+/// The encoding is not cosmetic: search carries user input, and an
+/// unescaped & would truncate the filter server-side and quietly
+/// return the wrong page.
+internal sealed class QueryString
+{
+ private readonly List _parts = [];
+
+ public QueryString Add(string key, string? value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ _parts.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}");
+ return this;
+ }
+
+ public QueryString Add(string key, int? value)
+ => value is null ? this : Add(key, value.Value.ToString());
+
+ public override string ToString() => _parts.Count == 0 ? "" : "?" + string.Join("&", _parts);
+}
diff --git a/src/LatticeService.cs b/src/LatticeService.cs
new file mode 100644
index 0000000..32298c7
--- /dev/null
+++ b/src/LatticeService.cs
@@ -0,0 +1,219 @@
+namespace MemMesh;
+
+/// Lattice — behavioral pattern intelligence. Mines a subject's event /
+/// memory history for repeatable behaviors, projects them forward, and exposes a
+/// context bundle a downstream AI step can hand to a message renderer.
+///
+/// Mirrors the TS reference surface (tf.lattice.*): extract/mine patterns,
+/// inspect/list them, fetch the context bundle, run the pattern-break monitor,
+/// predict (pattern-projection or declared-target), profile a subject, compute
+/// cohorts, run deterministic estimators, and read the calibration report.
+public sealed class LatticeService(MemMeshClient c)
+{
+ // ── Pattern extraction / mining ─────────────────────────────────────────
+
+ /// Force pattern (re-)extraction. Omit
+ /// and for a project-wide bulk run. Defaults to
+ /// mining the memory corpus (source='memories'); pass
+ /// source='contact_events' for the legacy contact-event path.
+ public Task ExtractPatternsAsync(string? contactId = null,
+ int? windowDays = null, bool? force = null, string? source = null,
+ Subject? subject = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary();
+ if (contactId is not null) body["contactId"] = contactId;
+ if (windowDays is not null) body["windowDays"] = windowDays;
+ if (force is not null) body["force"] = force;
+ if (source is not null) body["source"] = source;
+ if (subject is not null) body["subject"] = subject;
+ return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, options, ct);
+ }
+
+ /// Mine behavioral patterns from the memory corpus. Subject-agnostic:
+ /// works on any subject kind. Thin wrapper over
+ /// with source='memories'.
+ public Task MineMemoriesAsync(Subject? subject = null,
+ int? windowDays = null, bool? force = null, RequestOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["source"] = "memories" };
+ if (subject is not null) body["subject"] = subject;
+ if (windowDays is not null) body["windowDays"] = windowDays;
+ if (force is not null) body["force"] = force;
+ return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, options, ct);
+ }
+
+ /// Inspect a single pattern by id. 404s if it doesn't exist or belongs
+ /// to a different project.
+ public Task GetPatternAsync(string patternId,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Get,
+ $"lattice/patterns/{Uri.EscapeDataString(patternId)}", null, options, ct);
+
+ /// List behavior patterns Lattice has learned for a contact.
+ /// Cursor-paginated — pass the response's NextCursor back as
+ /// for the next page.
+ public Task ListPatternsAsync(string contactId,
+ bool? activeOnly = null, int? limit = null, string? cursor = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new List();
+ if (activeOnly is not null) q.Add($"activeOnly={(activeOnly.Value ? "true" : "false")}");
+ if (limit is not null) q.Add($"limit={limit}");
+ if (cursor is not null) q.Add($"cursor={Uri.EscapeDataString(cursor)}");
+ var path = $"lattice/contacts/{Uri.EscapeDataString(contactId)}/patterns"
+ + (q.Count > 0 ? "?" + string.Join("&", q) : "");
+ return c.Send(HttpMethod.Get, path, null, options, ct);
+ }
+
+ /// Full retrieval bundle for a contact — profile, active patterns,
+ /// recent events, recent memories, and (optionally) the entity/edge graph.
+ /// Supports bi-temporal replay via (ISO-8601).
+ public Task GetContextAsync(string contactId, string? asOf = null,
+ int? eventsLimit = null, int? memoriesLimit = null, int? graphHops = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new List();
+ if (asOf is not null) q.Add($"asOf={Uri.EscapeDataString(asOf)}");
+ if (eventsLimit is not null) q.Add($"eventsLimit={eventsLimit}");
+ if (memoriesLimit is not null) q.Add($"memoriesLimit={memoriesLimit}");
+ if (graphHops is not null) q.Add($"graphHops={graphHops}");
+ var path = $"lattice/contacts/{Uri.EscapeDataString(contactId)}/context"
+ + (q.Count > 0 ? "?" + string.Join("&", q) : "");
+ return c.Send(HttpMethod.Get, path, null, options, ct);
+ }
+
+ // ── Monitor ─────────────────────────────────────────────────────────────
+
+ /// Manually run the pattern-break monitor tick. The platform runs
+ /// this on a cron; manual triggers exist for debugging and tests that need an
+ /// overdue pattern to fire immediately.
+ public Task RunMonitorTickAsync(RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "lattice/monitor/tick",
+ new Dictionary(), options, ct);
+
+ /// Monitor health: timestamp of the last tick and a count of patterns
+ /// due for the next check — a liveness probe for the pattern-break dispatcher.
+ public Task GetMonitorStatusAsync(RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Get, "lattice/monitor/status", null, options, ct);
+
+ // ── Predict ─────────────────────────────────────────────────────────────
+
+ /// Predict for a subject. Two modes:
+ ///
+ /// Pattern projection (default). Omit to
+ /// project the subject's active behavior patterns forward.
+ /// Declared target (v2). Pass a to
+ /// predict anything from the subject's observation history; the estimate
+ /// arrives in PredictResult.TargetPrediction with first-class
+ /// abstention. Prefer the typed helper.
+ ///
+ public Task PredictAsync(Subject subject, int? horizonDays = null,
+ int? limit = null, double? minConfidence = null, int? occurrencesPerPattern = null,
+ bool? emitEvents = null, int? imminentWithinHours = null, PredictionTarget? target = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["subject"] = subject };
+ if (horizonDays is not null) body["horizonDays"] = horizonDays;
+ if (limit is not null) body["limit"] = limit;
+ if (minConfidence is not null) body["minConfidence"] = minConfidence;
+ if (occurrencesPerPattern is not null) body["occurrencesPerPattern"] = occurrencesPerPattern;
+ if (emitEvents is not null) body["emitEvents"] = emitEvents;
+ if (imminentWithinHours is not null) body["imminentWithinHours"] = imminentWithinHours;
+ if (target is not null) body["target"] = target;
+ return c.Send(HttpMethod.Post, "lattice/predict", body, options, ct);
+ }
+
+ /// v2 general prediction, typed: declare *what* to predict and get back
+ /// the single calibrated (or an abstention).
+ /// Thin wrapper over . Always check
+ /// Abstained before reading a value — an abstention means "unknown",
+ /// never "low risk". If the engine returns none, a synthetic abstention is
+ /// returned so callers never have to null-check.
+ public async Task PredictTargetAsync(Subject subject, PredictionTarget target,
+ int? horizonDays = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var result = await PredictAsync(subject, horizonDays: horizonDays, target: target,
+ options: options, ct: ct).ConfigureAwait(false);
+ return result.TargetPrediction ?? new TargetPrediction(
+ TargetKind: target.Kind,
+ EventType: target.EventType ?? target.AttributeKey ?? "",
+ Probability: 0, ProbabilityLower: 0, ProbabilityUpper: 0,
+ Value: 0, ValueLower: 0, ValueUpper: 0,
+ ExpectedAt: "", ExpectedAtLower: "", ExpectedAtUpper: "",
+ DaysUntil: 0, AnomalyScore: 0, IsAnomaly: false,
+ Abstained: true,
+ AbstentionReason: string.IsNullOrEmpty(result.AbstentionReason)
+ ? "insufficient_signal: engine returned no target estimate"
+ : result.AbstentionReason!,
+ Explanation: "", EvidenceMemoryIds: []);
+ }
+
+ // ── Profile / cohort ────────────────────────────────────────────────────
+
+ /// Behavioral profile snapshot for a subject — RFM segment + top
+ /// entity + cadence summary + risk indicators. Non-temporal counterpart to
+ /// : predict answers "what will they do?",
+ /// getProfile answers "who are they?".
+ public Task GetProfileAsync(Subject subject, RequestOptions? options = null,
+ CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "lattice/profile",
+ new Dictionary { ["subject"] = subject }, options, ct);
+
+ /// Find subjects whose behavior looks similar to the target — top-K
+ /// nearest neighbors ranked by a blended similarity score (RFM + entity
+ /// Jaccard + pattern-kind Jaccard).
+ public Task GetCohortAsync(Subject subject, int? k = null,
+ double? minSimilarity = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["subject"] = subject };
+ if (k is not null) body["k"] = k;
+ if (minSimilarity is not null) body["minSimilarity"] = minSimilarity;
+ return c.Send(HttpMethod.Post, "lattice/cohort", body, options, ct);
+ }
+
+ /// Cohort-aware predictions — "people like the target also did X."
+ /// Computes the target's cohort, then aggregates each member's predictions
+ /// weighted by similarity. Every prediction carries SupportingSubjects
+ /// + SourceMemoryIds so the answer is fully traceable.
+ public Task PredictByCohortAsync(Subject subject, int? cohortK = null,
+ int? predictionLimit = null, double? minSimilarity = null, RequestOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["subject"] = subject };
+ if (cohortK is not null) body["cohortK"] = cohortK;
+ if (predictionLimit is not null) body["predictionLimit"] = predictionLimit;
+ if (minSimilarity is not null) body["minSimilarity"] = minSimilarity;
+ return c.Send(HttpMethod.Post, "lattice/cohort/predict", body, options, ct);
+ }
+
+ // ── Estimate / calibration ──────────────────────────────────────────────
+
+ /// Run a deterministic estimator (e.g. PhenoAge biological age) over a
+ /// subject's biomarker signals. Returns a wellness estimate with per-signal
+ /// contributors and a not-a-diagnosis disclaimer — never a medical verdict.
+ /// Pass to store the score as a memory so it builds
+ /// a trajectory and feeds the calibration loop.
+ public Task EstimateAsync(Subject subject, string estimatorId,
+ bool? persist = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary
+ {
+ ["subject"] = subject, ["estimatorId"] = estimatorId,
+ };
+ if (persist is not null) body["persist"] = persist;
+ return c.Send(HttpMethod.Post, "lattice/estimate", body, options, ct);
+ }
+
+ /// Prediction calibration report: confidence buckets mapped to
+ /// realized hit-rates. Tells you whether "80% confident" predictions actually
+ /// fire ~80% of the time.
+ public Task GetCalibrationAsync(int? bucketCount = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var path = "lattice/calibration" + (bucketCount is not null ? $"?bucketCount={bucketCount}" : "");
+ return c.Send(HttpMethod.Get, path, null, options, ct);
+ }
+}
diff --git a/src/MemMesh.csproj b/src/MemMesh.csproj
index b626942..15fc3af 100644
--- a/src/MemMesh.csproj
+++ b/src/MemMesh.csproj
@@ -16,7 +16,15 @@
Apache-2.0https://memmesh.aihttps://github.com/ThinkfleetAI/memmesh-dotnet
+ gitai;agents;memory;llm;prediction;memmesh
+ README.md
+ true
+ snupkg
+
+
+
+
diff --git a/src/MemMeshClient.cs b/src/MemMeshClient.cs
index d97d7be..15ac85a 100644
--- a/src/MemMeshClient.cs
+++ b/src/MemMeshClient.cs
@@ -48,6 +48,8 @@ public sealed class MemMeshClient : IDisposable
public FinancialService Financial { get; }
public BrainsService Brains { get; }
public ConsentService Consent { get; }
+ /// The knowledge graph extraction builds from observed memory.
+ public GraphService Graph { get; }
/// Project API key (sk-...).
/// Default project for all calls; override per-call via .
@@ -87,6 +89,7 @@ public MemMeshClient(string apiKey, string projectId,
Financial = new FinancialService(this);
Brains = new BrainsService(this);
Consent = new ConsentService(this);
+ Graph = new GraphService(this);
}
// Relative to the project scope (no leading slash on `path`).
diff --git a/src/MemoryService.cs b/src/MemoryService.cs
index b46b9e3..0bde6ed 100644
--- a/src/MemoryService.cs
+++ b/src/MemoryService.cs
@@ -25,6 +25,7 @@ public async Task ObserveAsync(string? text = null, string role
string? content = null, Subject? subject = null,
string type = "event", string scope = "project", int importance = 5,
string? category = null, string? activityType = null, string? occurredAt = null,
+ string? userId = null, string? agentId = null, string? sessionId = null,
IDictionary? metadata = null, CancellationToken ct = default)
{
// PRIMARY path: raw text through the engine's noise filter. POST
@@ -34,6 +35,12 @@ public async Task ObserveAsync(string? text = null, string role
{
var observeBody = new Dictionary { ["text"] = text, ["role"] = role };
if (occurredAt is not null) observeBody["occurredAt"] = occurredAt;
+ // Provenance, all optional server-side. Added only when set, so a
+ // turn without them is indistinguishable from one made by an older
+ // client rather than carrying explicit nulls.
+ if (userId is not null) observeBody["userId"] = userId;
+ if (agentId is not null) observeBody["agentId"] = agentId;
+ if (sessionId is not null) observeBody["sessionId"] = sessionId;
return await c.Send(HttpMethod.Post, "memory/observe", observeBody, ct)
.ConfigureAwait(false);
}
diff --git a/src/Pagination.cs b/src/Pagination.cs
new file mode 100644
index 0000000..d2281a6
--- /dev/null
+++ b/src/Pagination.cs
@@ -0,0 +1,36 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json.Serialization;
+
+namespace MemMesh;
+
+/// One page of a seek-paginated list endpoint. Next and
+/// Previous are opaque cursors — pass Next back to fetch the
+/// following page; both are null at the respective ends.
+public sealed record SeekPage(
+ [property: JsonPropertyName("data")] IReadOnlyList Data,
+ [property: JsonPropertyName("next")] string? Next,
+ [property: JsonPropertyName("previous")] string? Previous);
+
+/// Cursor-following helpers over .
+public static class Pagination
+{
+ /// Flatten a seek-paginated endpoint into a single async stream,
+ /// following Next cursors until exhausted. Resources supply a
+ /// that fetches one page given a cursor
+ /// (null for the first page).
+ public static async IAsyncEnumerable ListAllAsync(
+ Func>> fetchPage,
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ string? cursor = null;
+ do
+ {
+ ct.ThrowIfCancellationRequested();
+ var page = await fetchPage(cursor, ct).ConfigureAwait(false);
+ if (page.Data is not null)
+ foreach (var item in page.Data)
+ yield return item;
+ cursor = page.Next;
+ } while (cursor is not null);
+ }
+}
diff --git a/src/RequestOptions.cs b/src/RequestOptions.cs
new file mode 100644
index 0000000..006c86d
--- /dev/null
+++ b/src/RequestOptions.cs
@@ -0,0 +1,27 @@
+namespace MemMesh;
+
+/// Per-call overrides threaded through the core send path. Pass one to
+/// any resource method that accepts a to redirect a
+/// single call to a different project or give it a different timeout, without
+/// building a second client.
+public sealed record RequestOptions
+{
+ /// Override the client's default project ID for this one call.
+ public string? ProjectId { get; init; }
+
+ /// Override the client's default request timeout for this one call.
+ public TimeSpan? Timeout { get; init; }
+}
+
+/// Runs before each request leaves the client. Mutate the request in
+/// place to add headers or swap the bearer token — e.g. exchange the API key for
+/// a short-lived Cognito JWT:
+///
+/// client.RequestInterceptors.Add(async (req, ct) =>
+/// req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetJwtAsync(ct)));
+///
+public delegate Task RequestInterceptor(HttpRequestMessage request, CancellationToken ct);
+
+/// Runs after each response arrives, before its body is read or parsed.
+/// Useful for logging, metrics, or inspecting response headers.
+public delegate Task ResponseInterceptor(HttpResponseMessage response, CancellationToken ct);
diff --git a/src/Services.cs b/src/Services.cs
index 7d62024..c14edc0 100644
--- a/src/Services.cs
+++ b/src/Services.cs
@@ -1,4 +1,6 @@
+using System.Globalization;
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace MemMesh;
@@ -55,87 +57,289 @@ private sealed record BatchBundles(List Bundles);
private sealed record GraphResult(List Edges);
}
-public sealed class LatticeService(MemMeshClient c)
+/// Learning — the closed-loop decision → action → outcome
+/// primitive. Where answers "what will
+/// happen?", the learning loop answers "did acting on it work?": record a
+/// decision (with links to the patterns/predictions that informed it), record its
+/// realized outcome, and every informing pattern's calibrated confidence moves
+/// toward what actually happened. rolls "what
+/// worked" up per action_type / decision_type / policy / pattern_kind.
+///
+/// Mirrors the TS reference surface (tf.learning.*). Domain-agnostic by
+/// design — subject/decision/action/outcome/reward only.
+public sealed class LearningService(MemMeshClient c)
{
- public Task PredictAsync(Subject subject, IDictionary target,
- CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "lattice/predict",
- new Dictionary { ["subject"] = subject, ["target"] = target }, ct);
+ /// Record a decision and its causal provenance.
+ /// defaults to "executed" server-side; re-sending an
+ /// returns the existing decision rather than duplicating.
+ public Task RecordDecisionAsync(Subject subject, string? actor = null,
+ string? decisionType = null, string? policy = null, IEnumerable? informedBy = null,
+ string? actionType = null, IDictionary? parameters = null, string? status = null,
+ string? occurredAt = null, IDictionary? metadata = null,
+ string? idempotencyKey = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["subject"] = subject };
+ if (actor is not null) body["actor"] = actor;
+ if (decisionType is not null) body["decisionType"] = decisionType;
+ if (policy is not null) body["policy"] = policy;
+ if (informedBy is not null) body["informedBy"] = informedBy;
+ if (actionType is not null) body["actionType"] = actionType;
+ if (parameters is not null) body["params"] = parameters;
+ if (status is not null) body["status"] = status;
+ if (occurredAt is not null) body["occurredAt"] = occurredAt;
+ if (metadata is not null) body["metadata"] = metadata;
+ if (idempotencyKey is not null) body["idempotencyKey"] = idempotencyKey;
+ return c.Send(HttpMethod.Post, "lattice/decisions", body, options, ct);
+ }
- public Task MineAsync(Subject? subject = null, CancellationToken ct = default)
+ /// Record the realized outcome of a decision. Folds the result into
+ /// the online calibrated confidence of every pattern the decision was
+ /// informedBy, and returns the before/after for each. A replayed
+ /// won't double-count calibration.
+ public Task RecordOutcomeAsync(string decisionId, string result,
+ Subject? subject = null, string? outcomeType = null, double? reward = null,
+ string? realizedAt = null, int? attributionWindowSecs = null,
+ IDictionary? metadata = null, string? idempotencyKey = null,
+ RequestOptions? options = null, CancellationToken ct = default)
{
- var body = new Dictionary();
+ var body = new Dictionary { ["decisionId"] = decisionId, ["result"] = result };
if (subject is not null) body["subject"] = subject;
- return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, ct);
+ if (outcomeType is not null) body["outcomeType"] = outcomeType;
+ if (reward is not null) body["reward"] = reward;
+ if (realizedAt is not null) body["realizedAt"] = realizedAt;
+ if (attributionWindowSecs is not null) body["attributionWindowSecs"] = attributionWindowSecs;
+ if (metadata is not null) body["metadata"] = metadata;
+ if (idempotencyKey is not null) body["idempotencyKey"] = idempotencyKey;
+ return c.Send(HttpMethod.Post, "lattice/outcomes", body, options, ct);
}
- public Task ProfileAsync(Subject subject, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "lattice/profile",
- new Dictionary { ["subject"] = subject }, ct);
+ /// List recorded outcomes for a subject (or the whole scope), newest
+ /// first. defaults to 100, clamped [1, 1000].
+ public async Task> GetOutcomesAsync(Subject? subject = null,
+ string? decisionType = null, string? actionType = null, int? limit = null,
+ RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new List();
+ if (subject is not null)
+ {
+ q.Add($"subjectKind={Uri.EscapeDataString(subject.Kind)}");
+ q.Add($"subjectExternalId={Uri.EscapeDataString(subject.ExternalId)}");
+ }
+ if (decisionType is not null) q.Add($"decisionType={Uri.EscapeDataString(decisionType)}");
+ if (actionType is not null) q.Add($"actionType={Uri.EscapeDataString(actionType)}");
+ if (limit is not null) q.Add($"limit={limit}");
+ var path = "lattice/outcomes" + (q.Count > 0 ? "?" + string.Join("&", q) : "");
+ var res = await c.Send(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false);
+ return res.Outcomes;
+ }
- public Task PredictByCohortAsync(Subject subject, IDictionary target,
- CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "lattice/cohort/predict",
- new Dictionary { ["subject"] = subject, ["target"] = target }, ct);
+ /// "What worked" roll-up — success rate, average reward, and posterior
+ /// confidence per group. is one of action_type /
+ /// decision_type / policy / pattern_kind (defaults to action_type);
+ /// returns only groups with at least that many
+ /// outcomes. Per-scope only.
+ public async Task> GetEffectivenessAsync(string? groupBy = null,
+ int? minSupport = null, RequestOptions? options = null, CancellationToken ct = default)
+ {
+ var q = new List();
+ if (groupBy is not null) q.Add($"groupBy={Uri.EscapeDataString(groupBy)}");
+ if (minSupport is not null) q.Add($"minSupport={minSupport}");
+ var path = "lattice/effectiveness" + (q.Count > 0 ? "?" + string.Join("&", q) : "");
+ var res = await c.Send(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false);
+ return res.Rows;
+ }
- public Task CalibrationAsync(CancellationToken ct = default)
- => c.Send(HttpMethod.Get, "lattice/calibration", null, ct);
+ private sealed record OutcomesResponse(
+ [property: JsonPropertyName("outcomes")] List Outcomes);
+ private sealed record EffectivenessResponse(
+ [property: JsonPropertyName("rows")] List Rows);
}
-public sealed class EventsService(MemMeshClient c)
+/// Behaviors — emergent behavior discovery. Where
+/// answers "what will this subject do?"
+/// and answers "who is this
+/// subject?", answers a project-wide question: "what
+/// behaviors exist in my data that nobody defined?" It clusters subjects by their
+/// feature vectors and surfaces the dense, cohesive groups as behaviors.
+///
+/// Mirrors the TS reference surface (tf.behaviors.*).
+public sealed class BehaviorsService(MemMeshClient c)
{
- public Task EmitAsync(IDictionary ev, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "events", ev, ct);
+ /// Discover emergent behaviors across the project. Returns clusters of
+ /// like-behaving subjects, sorted most-common-and-cohesive first. An empty
+ /// result means the engine abstained — not enough signal to assert any
+ /// behavior — never "there are no behaviors". All params are optional; the
+ /// engine clamps to safe ranges.
+ public Task DiscoverAsync(double? simThreshold = null, int? minClusterSize = null,
+ double? minStability = null, int? maxMembers = null, RequestOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var body = new Dictionary();
+ if (simThreshold is not null) body["simThreshold"] = simThreshold;
+ if (minClusterSize is not null) body["minClusterSize"] = minClusterSize;
+ if (minStability is not null) body["minStability"] = minStability;
+ if (maxMembers is not null) body["maxMembers"] = maxMembers;
+ return c.Send(HttpMethod.Post, "lattice/discover", body, options, ct);
+ }
}
-public sealed class AlertsService(MemMeshClient c)
+/// Compliance — GDPR-grade export, erasure, audit, and pack enablement.
+/// Two subject-scoped operations ( for Art. 15,
+/// for Art. 17) plus the audit log and the
+/// compliance-pack surface (installed packs + per-project enablement).
+///
+/// Mirrors the TS reference surface (tf.compliance.*); operations live
+/// under /memory-compliance/* and /memory-compliance-packs.
+public sealed class ComplianceService(MemMeshClient c)
{
- public Task CreateAsync(IDictionary rule, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "alerts", rule, ct);
- public Task> ListAsync(CancellationToken ct = default)
- => c.Send>(HttpMethod.Get, "alerts", null, ct);
- public Task DeleteAsync(string id, CancellationToken ct = default)
- => c.SendVoid(HttpMethod.Delete, $"alerts/{id}", null, ct);
-}
+ /// Art. 15 subject-access: return every memory, pattern, observation,
+ /// event, and alert fire for the subject in a single bundle.
+ public Task ExportSubjectAsync(Subject subject,
+ RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "memory-compliance/export",
+ new Dictionary { ["subject"] = subject }, options, ct);
-public sealed class LearningService(MemMeshClient c)
-{
- public Task RecordDecisionAsync(IDictionary d, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "learning/decisions", d, ct);
- public Task RecordOutcomeAsync(IDictionary o, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "learning/outcomes", o, ct);
- public Task GetEffectivenessAsync(IDictionary q, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "learning/effectiveness", q, ct);
-}
+ /// Art. 17 right-to-erasure: cascade-delete the same set and write a
+ /// tombstone audit row. is required (a case id);
+ /// previews without touching any rows.
+ public Task HardDeleteSubjectAsync(Subject subject, string reason,
+ bool dryRun = false, RequestOptions? options = null, CancellationToken ct = default)
+ => c.Send(HttpMethod.Post, "memory-compliance/hard-delete",
+ new Dictionary { ["subject"] = subject, ["reason"] = reason, ["dryRun"] = dryRun },
+ options, ct);
-public sealed class TypedService(MemMeshClient c)
-{
- public Task RegisterAttributeAsync(IDictionary def, CancellationToken ct = default)
- => c.Send(HttpMethod.Post, "typed/attributes", def, ct);
- public Task IngestAsync(IEnumerable