memory-lab
An agent with long-term memory — notes it keeps about a subject, across conversations.
5 source files2 test files

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. }