Writing an extension
This page takes you from an empty folder to an extension PremAgentic loads: a reader for a made-up kind of file, proven with the conformance fixtures, installed, allowed by hash, shown as loaded, then upgraded, with the refusals you meet on the way. Every command and file was run as written, on Windows in PowerShell; on Linux or macOS the slashes turn. How the host hashes, loads and refuses an extension is on the Extensions page. This page is the doing.
An extension is a folder holding one assembly and an extension.json beside
it; the assembly has a public class implementing IExtension, and nothing
else marks it. The layout used here:
work\ premagentic\ your clone of this repository note-reader\ the extension project note-reader-tests\ its conformance tests extensions\ stands in for a deployment's extensions folder
You need the .NET 10 SDK, Docker, and the README's quick start done once in
premagentic, so that prem reaches a database from your shell.
01The extension
note-reader\NoteReader.csproj. The one reference is Premagentic.Core,
with Private="false" so no copy lands beside your assembly: the host
supplies it. The target writes the manifest after every build, with the hash
of the assembly just built.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>NoteReader</AssemblyName>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\premagentic\src\Premagentic.Core\Premagentic.Core.csproj" Private="false" />
</ItemGroup>
<Target Name="WriteExtensionManifest" AfterTargets="Build">
<GetFileHash Files="$(TargetPath)" Algorithm="SHA256" HashEncoding="hex">
<Output TaskParameter="Items" ItemName="HashedAssembly" />
</GetFileHash>
<PropertyGroup>
<AssemblyHash>@(HashedAssembly->'%(FileHash)')</AssemblyHash>
<ManifestText>{
"name": "note-reader",
"version": "$(Version)",
"assemblyFile": "$(TargetFileName)",
"sha256": "$(AssemblyHash)",
"seams": { "reader": 1 }
}</ManifestText>
</PropertyGroup>
<WriteLinesToFile File="$(TargetDir)extension.json" Lines="$(ManifestText)" Overwrite="true" WriteOnlyWhenDifferent="true" />
</Target>
</Project>
Set the version here, never with -p:Version= on the command line: that is
a global property, it stamps the Premagentic.Core reference too, and the
host then refuses the assembly with did not load for asking a newer core.
note-reader\NoteReaderExtension.cs: the extension, and the reader it
registers. A .note file is plain UTF-8 text whose first line is the title;
no built-in reader claims it, and one reader gives every connector the
format at once, because connectors find paths and readers read them.
using System.Text;
using Premagentic.Core.Extensions;
using Premagentic.Core.Ingestion.Readers;
namespace NoteReader;
public sealed class NoteReaderExtension : IExtension
{
public string Name => "note-reader";
public void Register(ExtensionRegistrations registrations) =>
registrations.AddReader(new NoteFileReader());
}
public sealed class NoteFileReader : IDocumentReader
{
public string Name => "note";
public IReadOnlyList<string> Extensions { get; } = [".note"];
public async Task<ReadDocument> ReadAsync(Stream content, string path, CancellationToken ct)
{
using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var text = await reader.ReadToEndAsync(ct);
var firstLine = text.Split('\n', 2)[0].Trim();
return new ReadDocument(text, firstLine.Length == 0 ? null : firstLine, null);
}
}
The whole text is indexed, title line included, so a search can cite it. A reader returns text taken from the file and writes nothing of its own, because what it returns is what a search serves and cites. Build it:
dotnet build .\note-reader -c Release
02Prove it keeps the contract
note-reader-tests\NoteReader.Tests.csproj references the conformance
project and the extension:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\premagentic\tests\Premagentic.Conformance\Premagentic.Conformance.csproj" />
<ProjectReference Include="..\note-reader\NoteReader.csproj" />
</ItemGroup>
</Project>
note-reader-tests\NoteFileReaderConformanceTests.cs inherits
DocumentReaderConformance, hands it the reader, and gives it one invented
file with the words its text must carry:
using System.Text;
using Premagentic.Conformance;
using Premagentic.Core.Ingestion.Readers;
namespace NoteReader.Tests;
public sealed class NoteFileReaderConformanceTests : DocumentReaderConformance
{
protected override IDocumentReader Reader => new NoteFileReader();
protected override IReadOnlyList<ReaderSample> Samples { get; } =
[
new("closing.note",
Encoding.UTF8.GetBytes("Closing the yard\nThe last van leaves at six.\nThe gate is locked after it.\n"),
["Closing the yard", "The last van leaves at six.", "The gate is locked after it."]),
];
}
dotnet test .\note-reader-tests
It prints Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3: it names
itself and the extensions it reads, it returns what is in the file, and an
empty file does not throw and has no text. Break the reader once to see the
fixture work: return a description instead of the text and
It_returns_what_is_in_the_file fails with "A reader returns the text of the
file, not a description of it." Chunkers, connectors and embedding providers
have fixtures of their own in the same project.
03Install it
An administrator installs an extension by putting its folder under the
extensions folder and allowing it. From premagentic:
cd .\premagentic New-Item -ItemType Directory -Force ..\extensions\note-reader | Out-Null $env:PREM_EXTENSIONS_DIR = (Resolve-Path ..\extensions).Path Copy-Item ..\note-reader\bin\Release\net10.0\NoteReader.dll, ..\note-reader\bin\Release\net10.0\extension.json ..\extensions\note-reader dotnet run --project src/Premagentic.Cli -- extensions list
PREM_EXTENSIONS_DIR names the extensions folder for this shell; a
deployment sets extensions.folder instead, as a full path. Nothing loads
yet, and that is the default: the list shows note-reader (not allowed)
under Refused, and the deployment runs with the built-ins. allow reads the
manifest, hashes the assembly itself, and writes the name and that hash to
extensions.allowed, with an entry in the change record. Every prem
command is a new process, so the list that follows shows it loaded, and from
then on every source that holds .note files reads them.
dotnet run --project src/Premagentic.Cli -- extensions allow ..\extensions\note-reader dotnet run --project src/Premagentic.Cli -- extensions list
Allowed note-reader with hash cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9. It loads the next time a Premagentic process starts. Loaded: note-reader 1.0.0 cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9
04When the hash is wrong
Change the version in NoteReader.csproj to 1.0.1, build again, and copy
only the assembly, which is the mistake an upgrade invites:
dotnet build ..\note-reader -c Release Copy-Item ..\note-reader\bin\Release\net10.0\NoteReader.dll ..\extensions\note-reader dotnet run --project src/Premagentic.Cli -- extensions list
Refused:
note-reader (hash mismatch)
"NoteReader.dll" hashes to fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936 and the manifest says cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9.
Copy the manifest too and the reason changes to not allowed: the pair is
consistent, but nobody allowed this hash. A new assembly is a new hash, and
an upgrade is allowed again on purpose:
Copy-Item ..\note-reader\bin\Release\net10.0\extension.json ..\extensions\note-reader dotnet run --project src/Premagentic.Cli -- extensions allow ..\extensions\note-reader dotnet run --project src/Premagentic.Cli -- extensions list
Loaded: note-reader 1.0.1 fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936 Allowed (extensions.allowed): note-reader cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9 (not in the extensions folder) note-reader fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936 (loaded)
The earlier hash stays allowed, so the old build can go back without a new
allow, and prem extensions disallow note-reader drops every hash under the
name. Every other refusal reads the same way, and none stops the deployment.
What you ship is the folder: the assembly and its manifest, nothing else.
The administrator copies it under their extensions folder, runs prem
extensions allow on it, and restarts. Build against the release they run or
an older one, keep every fixture invented, and if the seam you need does not
exist, that is a proposal for an issue; CONTRIBUTING.md says how.