Skip to content
Open
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
16 changes: 16 additions & 0 deletions sdks/dotnet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## .NET build output
bin/
obj/
*.user

## Test / coverage artifacts
[Tt]est[Rr]esults/
*.trx
*.coverage
coverage*.json
coverage*.xml
coverage*.cobertura.xml

## NuGet
*.nupkg
*.snupkg
27 changes: 27 additions & 0 deletions sdks/dotnet/Moss.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss", "src\Moss\Moss.csproj", "{A1B2C3D4-0001-4000-8000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss.Tests", "tests\Moss.Tests\Moss.Tests.csproj", "{A1B2C3D4-0002-4000-8000-000000000002}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
112 changes: 112 additions & 0 deletions sdks/dotnet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Moss .NET SDK

The .NET SDK for [Moss](https://github.com/usemoss/moss) — fast on-device
retrieval. It wraps the native `libmoss` runtime through P/Invoke and exposes an
idiomatic, async C# API for index management, hybrid search, and metadata
filtering.

## Architecture

```
┌──────────────────────────────────┐
│ Your application code │
└──────────────┬───────────────────┘
┌──────────────▼───────────────────┐
│ Moss (managed C#) │ ← src/Moss
│ MossClient — async API for │
│ indexing, querying, management │
└──────────────┬───────────────────┘
│ P/Invoke ([DllImport("moss")])
┌──────────────▼───────────────────┐
│ libmoss (native C ABI) │ ← prebuilt runtime
│ hybrid search, data models │
└──────────────────────────────────┘
```

- `src/Moss/` — the public SDK. `MossClient` plus the data models.
- `src/Moss/Interop/` — the P/Invoke layer: raw `libmoss` declarations, C-ABI
struct mirrors, UTF-8 marshaling, and native-memory conversion/cleanup.

The interop layer targets the same stable C ABI (`libmoss.h`) that the Go
bindings bind via cgo.

## Quick start

```csharp
using Moss;

using var client = new MossClient("your_project_id", "your_project_key");

await client.CreateIndexAsync("support-docs", new[]
{
new DocumentInfo("1", "Refunds are processed within 3-5 business days."),
new DocumentInfo("2", "You can track your order on the dashboard."),
});

await client.LoadIndexAsync("support-docs");

var results = await client.QueryAsync(
"support-docs", "how long do refunds take?", new QueryOptions { TopK = 3 });

foreach (var doc in results.Docs)
Console.WriteLine($"[{doc.Score:F3}] {doc.Text}");
```

### Metadata filtering

Attach string metadata at index time and pass a JSON filter at query time:

```csharp
await client.AddDocsAsync("support-docs", new[]
{
new DocumentInfo("3", "EU refund policy…",
metadata: new Dictionary<string, string> { ["region"] = "eu" }),
});

var results = await client.QueryAsync("support-docs", "refund policy",
new QueryOptions
{
TopK = 5,
FilterJson = "{\"region\": \"eu\"}",
});
```

## API surface

| Area | Methods |
|------|---------|
| Indexes | `CreateIndexAsync`, `GetIndexAsync`, `ListIndexesAsync`, `DeleteIndexAsync` |
| Documents | `AddDocsAsync`, `DeleteDocsAsync`, `GetDocsAsync` |
| Jobs | `GetJobStatusAsync` |
| Local runtime | `LoadIndexAsync`, `UnloadIndexAsync`, `RefreshIndexAsync`, `QueryAsync` |

All methods are asynchronous and accept a `CancellationToken`. Failures from the
native runtime surface as `MossException` (carrying the status `Code` and the
`moss_last_error` message).

## The native runtime

The SDK calls into `libmoss`, distributed as a prebuilt native library
(`libmoss.so` on Linux, `libmoss.dylib` on macOS, `moss.dll` on Windows). It
must be discoverable at runtime — on the standard library search path, next to
your application, or via `NativeLibrary` resolution. Building and unit-testing
the SDK does **not** require the native library; only running queries does.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONSIDER The runtime requirement is understated. MossClient calls moss_client_new in its constructor, so any valid client creation or management call requires the native library, not only queries.

Building and unit-testing the SDK does **not** require the native library; only running queries does.

Fix the docs to say libmoss is required whenever an application constructs/uses MossClient, or lazy-initialize native runtime only for local query operations if management APIs are meant to work without it.


## Building and testing

Requires the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0).

```bash
cd sdks/dotnet
dotnet build
dotnet test # unit tests run without libmoss
```

The unit tests cover the managed logic and the marshaling layer (UTF-8
round-trips, native buffer packing, ABI struct sizes) and do not load the native
library.

## License

[BSD 2-Clause License](../../LICENSE)
26 changes: 26 additions & 0 deletions sdks/dotnet/src/Moss/Interop/MossClientHandle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Runtime.InteropServices;

namespace Moss.Interop;

/// <summary>
/// Owns the native <c>MossClient*</c>. Deriving from <see cref="SafeHandle"/>
/// means the handle is reference-counted during P/Invoke calls and freed via
/// <c>moss_client_free</c> from <see cref="ReleaseHandle"/> — including during
/// finalization, so a leaked (undisposed) client still releases native state.
/// </summary>
internal sealed class MossClientHandle : SafeHandle
{
public MossClientHandle() : base(IntPtr.Zero, ownsHandle: true) { }

public override bool IsInvalid => handle == IntPtr.Zero;

/// <summary>Adopt a raw handle produced by <c>moss_client_new</c>.</summary>
internal void SetRawHandle(IntPtr raw) => SetHandle(raw);

protected override bool ReleaseHandle()
{
NativeMethods.moss_client_free(handle);
return true;
}
}
88 changes: 88 additions & 0 deletions sdks/dotnet/src/Moss/Interop/Native.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;

namespace Moss.Interop;

/// <summary>UTF-8 string marshaling helpers for the native boundary.</summary>
internal static class Utf8
{
/// <summary>Allocates a NUL-terminated UTF-8 copy of <paramref name="value"/>, or
/// <see cref="IntPtr.Zero"/> when it is null. Free with <see cref="Marshal.FreeCoTaskMem"/>.</summary>
public static IntPtr Alloc(string? value)
=> value is null ? IntPtr.Zero : Marshal.StringToCoTaskMemUTF8(value);

/// <summary>Reads a NUL-terminated UTF-8 string, mapping a null pointer to "".</summary>
public static string Read(IntPtr ptr)
=> ptr == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(ptr) ?? string.Empty;

/// <summary>Reads a NUL-terminated UTF-8 string, preserving null as null (for optional fields).</summary>
public static string? ReadOptional(IntPtr ptr)
=> ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
}

/// <summary>
/// Tracks native allocations made while marshaling inputs and frees them all on
/// <see cref="Dispose"/>. Strings are allocated via CoTaskMem; raw blocks via HGlobal.
/// </summary>
internal sealed class NativeArena : IDisposable
{
private readonly List<IntPtr> _coTaskMem = new();
private readonly List<IntPtr> _hGlobal = new();

/// <summary>Allocate a UTF-8 string tracked by this arena.</summary>
public IntPtr String(string? value)
{
IntPtr p = Utf8.Alloc(value);
if (p != IntPtr.Zero) _coTaskMem.Add(p);
return p;
}

/// <summary>Allocate a raw block of <paramref name="bytes"/> bytes tracked by this arena.</summary>
public IntPtr Alloc(int bytes)
{
IntPtr p = Marshal.AllocHGlobal(bytes);
_hGlobal.Add(p);
return p;
}

/// <summary>Marshal an array of contiguous structs into a tracked native block.</summary>
public IntPtr StructArray<T>(IReadOnlyList<T> items) where T : struct
{
if (items.Count == 0) return IntPtr.Zero;
int size = Marshal.SizeOf<T>();
IntPtr block = Alloc(size * items.Count);
for (int i = 0; i < items.Count; i++)
Marshal.StructureToPtr(items[i], block + i * size, false);
return block;
}

/// <summary>Marshal an array of floats into a tracked native block.</summary>
public IntPtr FloatArray(IReadOnlyList<float>? values)
{
if (values is null || values.Count == 0) return IntPtr.Zero;
IntPtr block = Alloc(sizeof(float) * values.Count);
var tmp = new float[values.Count];
for (int i = 0; i < values.Count; i++) tmp[i] = values[i];
Marshal.Copy(tmp, 0, block, tmp.Length);
return block;
}

/// <summary>Marshal an array of UTF-8 strings into a tracked native array of char*.</summary>
public IntPtr StringArray(IReadOnlyList<string> values)
{
if (values.Count == 0) return IntPtr.Zero;
IntPtr block = Alloc(IntPtr.Size * values.Count);
for (int i = 0; i < values.Count; i++)
Marshal.WriteIntPtr(block, i * IntPtr.Size, String(values[i]));
return block;
}

public void Dispose()
{
foreach (IntPtr p in _coTaskMem) Marshal.FreeCoTaskMem(p);
foreach (IntPtr p in _hGlobal) Marshal.FreeHGlobal(p);
_coTaskMem.Clear();
_hGlobal.Clear();
}
}
Loading
Loading