Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Samples

memory-lab

An agent with long-term memory — notes it keeps about a subject, across conversations.

5 source files2 test files

The memory-lab sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init memory-lab
$ osy launch

The app

app.osy8 lines
// An agent with long-term memory — notes it keeps about a subject, across conversations.
// The memory engine with an AGENT on the other end of it, instead of an eval harness.
app MemoryLab {
  use Osyrin.Memory;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/auth.osy96 lines
// The smallest real login, taken verbatim from `demo/dialog-demo` because it is known to compile and is not the
// subject here. It matters for one reason: an MCP client authenticates AS a principal, and memory is reachable only
// through records that principal may read — so driving this app over MCP exercises the security path too, which no
// eval run has ever done.

// `Operator` is the one who may look at what the corpus costs and shrink it (model/operator.osy).
[Role] enum AppRole { Authenticator, Member, Operator }

entity RoleGrant {
  User Grantee;
  [Required] AppRole Level;
  security {
    allow create when IsAuthenticator;
    // ⚠ BY ROLE, and the `IsAuthenticated` grant below cannot stand in for it. `Login` runs as the armed auth
    //   principal, which BEARS the Authenticator role and has NO user — working out the user is what it was called
    //   to do. `IsAuthenticated` asks about a signed-in user, so it matched nothing here, `IsAuthenticator` could
    //   not read the very rows that define it, and LOGIN ALWAYS FAILED: a correct password refused exactly like a
    //   wrong one, with no error anywhere. Signing up still worked, which is what hid it.
    allow read when IsAuthenticator;
    allow read when IsAuthenticated;
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);

[Principal] entity User {
  [Required, MaxLength(200), Unique] string Email;
  [MaxLength(200)] string PasswordHash;
  security {
    allow read, create when IsAuthenticator;
    allow read when IsAuthenticated;
    deny read PasswordHash when !IsAuthenticator;
  }
}

[AuthMethod]
string Signup(string email, string password) {
  if (password.Length < 8) { return ""; }
  bool isFirst = User.Count() == 0;
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  var g = new RoleGrant { Grantee = u, Level = isFirst ? AppRole.Operator : AppRole.Member };
  return Security.IssueJwt(u.Id, u.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap {
  Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage,
};

/// The catalog gate. `VisibleTo` needs a DECLARED policy — `IsAuthenticated` is a builtin usable in a `security`
/// block but not nameable here, and the compiler says so and lists the alternatives. Any signed-in member may
/// drive the tools; the interesting restriction is not this one, it is that memory is reachable only through
/// records the caller may read, which no policy here has to state.
policy IsMember => RoleGrant.Any(g => g.Grantee == user);

[Page("/login")]
[Render(CSR)]
[AllowAnonymous]
[Title("Sign in")]
component LoginPage() {
  string email = "";
  string password = "";
  string problem = "";

  action CreateAccount() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "Pick a password of at least 8 characters."; }
    else { Session.SignIn(ticket); }
  }

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password don't match an account."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Stack(gap: 3, w: "320px", p: 5) {
      Text("Memory lab");
      Text("Any email and a password of 8+ characters — this is a sample.");
      Input(value: email, label: "Email", placeholder: "you@example.com", onEnter: SignIn);
      Input(value: password, label: "Password", type: "password", placeholder: "password", onEnter: SignIn);
      if (problem != "") { Text(problem); }
      Osyrin.Button("Sign in", onClick: SignIn);
      Osyrin.Button("Create account", onClick: CreateAccount);
    }
  }
}
model/memory.osy75 lines
// The lab itself: two entities, five verbs, and an MCP catalog that hands all of it to an agent.
using Osyrin.Memory;

/// Something memories are ABOUT — a customer, a project, a person. The anchor: memory is reachable only through a
/// record the caller may read, so this is also what makes the security path real rather than theoretical.
entity Subject {
  [Required, MaxLength(160)] string Name;
  [MaxLength(600)] string? Summary;
  [ForeignKey(Subject)] Note[] Notes;
  security {
    allow read, create, update when IsAuthenticated;
  }
}

/// One remembered thing. `Body` is the whole point: [Searchable(Memory)] is what puts it through the real chunk
/// writer — split into short pieces for matching, kept whole as the answer — so an agent writing here is
/// exercising small-to-big and not a fixture.
entity Note {
  [Required] Subject Subject;
  [Required, Searchable(Memory)] string Body;
  security {
    allow read, create when IsAuthenticated;
  }
}

/// Remember something about a subject. Returns the note's id so an agent can refer to it afterwards.
Guid Remember(Subject subject, string body) {
  var n = new Note { Subject = subject, Body = body };
  return n.Id;
}

/// Search everything the caller may read. The plain case, and the one where an agent's own phrasing — not a
/// corpus generator's — decides whether retrieval works.
List<SearchHit> Recall(string query) {
  return Memory.Search(query, limit: 5);
}

/// Search narrowed to ONE subject.
List<SearchHit> RecallAbout(Subject subject, string query) {
  return Memory.Search(query, about: [subject], limit: 5);
}

/// The same, following stated links one hop out. Anything reached that way comes back with `Via` filled in, so an
/// agent can tell "this is about what you asked" from "this is about something related to it" — which is the
/// distinction the whole hop design turns on.
List<SearchHit> RecallRelated(Subject subject, string query) {
  return Memory.Search(query, about: [subject], related: 1, limit: 8);
}

/// State that two subjects are related, in words.
bool Relate(Subject a, Subject b, string reason, string reverseReason) {
  return Memory.Link(a, b, reason: reason, reverseReason: reverseReason);
}

app.McpServer = new McpServer {
  Catalogs = [
    new ToolCatalog("memory") {
      Description = "Write and recall memories about subjects. Remember something, then ask for it later in your "
        + "own words — the wording does not have to match. `RecallAbout` narrows to one subject; `RecallRelated` "
        + "also follows stated links one hop out and marks what it reached with `Via`. Writing a correction later "
        + "and asking again is the interesting case: the more recent memory is preferred when both match equally.",
      VisibleTo = IsMember,
      Tools = [
        new CrudTool<Subject>() { Operations = [CrudOp.Create, CrudOp.Read, CrudOp.Update] },
        new Tool(Remember),
        new Tool(Recall),
        new Tool(RecallAbout),
        new Tool(RecallRelated),
        new Tool(Relate),
        new Tool(Knowledge.Search),
      ],
    },
  ],
};
model/operator.osy103 lines
// THE OPERATOR'S HALF OF THE LAB — what the corpus costs, and the decision to give some of it back.
using Osyrin.Memory;

/// Somebody who may look at what the corpus costs and decide to shrink it.
policy IsOperator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Operator);

/// ⚑ **SAID ONCE, HERE, AND THEN NEVER AGAIN.** The platform gates `Memory.Statistics` and `Memory.Prune` on this
/// policy — so the two functions below are ordinary functions with no authority code in them, and so is any
/// second entry point that ever reaches these verbs (an MCP tool, an endpoint, another function). It is not
/// optional: an app that calls them without this declaration does not COMPILE.
app.Memory = new MemoryConfig { Operator = IsOperator };

/// What the corpus costs, and what pruning at this cutoff would give back. Counted, never scanned, so trying 30,
/// 90 and 180 days to find the shape of the curve is cheap — which is the intended use, since nobody knows their
/// own retention curve before they look at it.
MemoryStatistics MemoryUsage(int unusedForDays) {
  return Memory.Statistics(DateTime.UtcNow.AddDays(-unusedForDays));
}

/// Drop the search vector from derived memory nothing has returned since the cutoff. The text stays, so an entry
/// is still readable and still matched by exact words, and it re-indexes the moment anything changes it. Memory
/// somebody CHOSE to keep is never eligible — that rule is the platform's, not this function's to relax.
int PruneMemory(int unusedForDays) {
  return Memory.Prune(DateTime.UtcNow.AddDays(-unusedForDays));
}

/// Whether the caller may manage this app's memory — the policy above, NAMED as an ordinary boolean.
bool CanManageMemory() {
  return IsOperator;
}

[Page("/")]
[Render(CSR)]
[Authorize(IsOperator)]
[Title("Memory usage")]
component MemoryOperatorPage() {
  int days = 90;
  MemoryStatistics? usage = null;
  int checkedForDays = 0;
  string outcome = "";

  action Check() {
    usage = MemoryUsage(days);
    checkedForDays = days;
    outcome = "";
  }

  action Prune() {
    var freed = PruneMemory(days);
    outcome = freed == 0
      ? "Nothing to reclaim — everything derived has been returned since then."
      : freed + " entries released their search index. Their text is unchanged.";
    usage = MemoryUsage(days);
    checkedForDays = days;
  }

  render {
    Stack(gap: 4, w: "560px", p: 5) {
      Text("Memory usage");

      Row(gap: 2) {
        Input(value: days, label: "Unused for");
        Text("days");
        Osyrin.Button("Check", onClick: Check);
      }

      if (usage == null) {
        Text("Nothing measured yet. Pick a cutoff and check — it is a count, so asking repeatedly is cheap.");
      }

      if (usage != null) {
        Stack(gap: 1) {
          if (usage.StoredBytes == null) {
            Text("On disk: unknown — this store cannot report its size.");
          }
          if (usage.StoredBytes != null) {
            Text("On disk: " + (usage.StoredBytes / 1048576) + " MB (measured — rows, text and every index)");
          }
          Text(usage.Entries + " entries, " + usage.Vectors + " of them indexed (~"
            + (usage.VectorBytes / 1048576) + " MB of vectors)");
          Text(usage.NeverUsed + " have never been returned by a search");
          Text(usage.Authored + " were remembered on purpose — never pruned, at any cutoff");
          Text(usage.Derived + " follow the data, so their text survives losing a vector");
        }

        if (checkedForDays == days) {
          Stack(gap: 2) {
            Text("Pruning at " + days + " days releases " + usage.Reclaimable + " entries (~"
              + (usage.ReclaimableBytes / 1048576) + " MB). Nothing is deleted.");
            Osyrin.Button("Prune", onClick: Prune, disabled: usage.Reclaimable == 0);
          }
        }
        if (checkedForDays != days) {
          Text("Those figures are for " + checkedForDays + " days. Check again to see what "
            + days + " would release.");
        }
      }

      if (outcome != "") { Text(outcome); }
    }
  }
}
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical `model/theme.osy`, and that test is what keeps them identical.
//
// ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point.
//   The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`,
//   `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own
//   `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment
//   one of them reached for a kit control, that control painted itself indigo next to the demo's
//   blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that
//   existed only in the theme file.
//
// So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name
// the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it
// does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything.
//
// Excluded by design — these five own their look and must NOT adopt this file: arcade, ember,
// motion, gestures (each demonstrates a visual world) and Apps/recall-osy.

theme Demo {

  Colors {
    // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a
    // considered app rather than an unstyled one, and the two should be distinguishable on sight.
    // It is the one saturated colour on the page; everything else is warm neutral, which is what
    // keeps "distinct" from turning into "loud".
    Primary   = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6"));
    OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F");

    Bg      = Modes.Of(light: "#FAF9F7", dark: "#101317");   // the page — a touch warm, so cards read as lifted
    Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22");   // a card, a row, a lane
    Muted   = Modes.Of(light: "#EFEEEA", dark: "#1E242B");   // an inline notice, a disabled field
    Border  = Modes.Of(light: "#E4E2DC", dark: "#28303A");

    OnBg          = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    OnSurface     = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3");   // labels, timestamps, captions
    TextMuted     = Modes.Of(light: "#7C848E", dark: "#7E8894");   // the quietest text on the page

    Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A"));
    Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343"));
    Danger  = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A"));
  }

  // Names the kit does not have, so these are extensions rather than second spellings.
  Radius { Control = "8px"; Card = "12px"; }

  // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans`
  //   shadows the kit's own token — so every kit control picks it up with no page edit at all.
  //   Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font
  //   add`), because a webfont a page merely NAMES is a webfont that silently falls back.
    //   Only the faces the app SHIPS are named here — Mono is left at the kit default because no
  //   demo renders code, and naming an unshipped family is a silent fallback.
  //   Geist for the UI: a neutral grotesque with real character at small sizes, which is where a
  //   dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page
  //   title. The pairing is the whole look — one voice for reading, one for announcing.
  Font {
    Sans    = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif";
    Serif   = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif";
  }

  FontSize   { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; }

  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
}