<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roni Yusuf</title>
    <description>The latest articles on DEV Community by Roni Yusuf (@rymanalu).</description>
    <link>https://dev.to/rymanalu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4172735%2F7c1be90b-dfcb-4d29-a6d4-0d6ec6f7897d.jpg</url>
      <title>DEV Community: Roni Yusuf</title>
      <link>https://dev.to/rymanalu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rymanalu"/>
    <language>en</language>
    <item>
      <title>Corrected vs. ended: two ways a fact stops being true</title>
      <dc:creator>Roni Yusuf</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/rymanalu/corrected-vs-ended-two-ways-a-fact-stops-being-true-1g0h</link>
      <guid>https://dev.to/rymanalu/corrected-vs-ended-two-ways-a-fact-stops-being-true-1g0h</guid>
      <description>&lt;p&gt;A fact can stop being true in two different ways. The world can change: the &lt;code&gt;acme-api&lt;/code&gt; project deployed from &lt;code&gt;main&lt;/code&gt;, and now it deploys from &lt;code&gt;trunk&lt;/code&gt;. Or the fact was never true: I wrote down that it runs on Postgres 15, and it runs on 16. Both facts have to stop being returned, but they are different events. The first was true for a while. The second never was, and the record that I once believed it is worth keeping.&lt;/p&gt;

&lt;p&gt;A common schema folds both into one &lt;code&gt;UPDATE&lt;/code&gt;, or one &lt;code&gt;valid_to&lt;/code&gt; column, and loses which. That loss gets expensive when the writer is a coding agent, because the writer is often wrong, and it retries.&lt;/p&gt;

&lt;p&gt;lontar is the memory store I built for that case: a Postgres database behind six MCP tools, run locally as a Claude Code plugin. &lt;a href="https://lontar.dev/posts/one-memory-across-ai-agents" rel="noopener noreferrer"&gt;The first post&lt;/a&gt; covers what it takes to share one memory across agents. This one is about the two ways a fact in it can stop being true, and the rules that keep them apart.&lt;/p&gt;

&lt;p&gt;Everything I describe about lontar is in the code today, and where a test pins a rule, I name the test. Not every rule has one, so I don't claim they all do. Anything that isn't built is marked &lt;strong&gt;designed&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two timelines
&lt;/h2&gt;

&lt;p&gt;Every fact row carries two intervals, one on the world axis and one on the belief axis. &lt;code&gt;valid_from&lt;/code&gt; and &lt;code&gt;valid_to&lt;/code&gt; say when the statement was true in the world. &lt;code&gt;recorded_at&lt;/code&gt; and &lt;code&gt;invalidated_at&lt;/code&gt; say when the store held it as something it believed. A fact stops being true by changing one end of one of them, and which end is the whole difference between ended and corrected.&lt;/p&gt;

&lt;p&gt;Both intervals are half-open: &lt;code&gt;valid_from&lt;/code&gt; and &lt;code&gt;recorded_at&lt;/code&gt; are inclusive, &lt;code&gt;valid_to&lt;/code&gt; and &lt;code&gt;invalidated_at&lt;/code&gt; exclusive. A fact valid until April 14 is gone at the instant of April 14. The queries exist twice, as SQL and as a pure function, and a contract test, "the SQL predicates agree with domain/temporal.ts on every boundary", drives both over query points that land exactly on each boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;valid_from&lt;/code&gt; has no database default. The application fills it in when the caller omits it, so an insert that forgets it fails instead of quietly collapsing the model into one timeline.&lt;/p&gt;

&lt;p&gt;Here is the running example, invented, for a project called &lt;code&gt;acme-api&lt;/code&gt;. On February 2 an agent records "deploys from main" and "runs on Postgres 15". On April 14 the team moves to trunk, so the first fact is ended and "deploys from trunk" starts that day. On June 9 the team finds the database was on 16 all along, so the second fact is corrected, and "runs on Postgres 16" replaces it with the same window. What &lt;code&gt;recall_facts&lt;/code&gt; returns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Called with&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;deploys from trunk; runs on Postgres 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;as_of&lt;/code&gt; March 1&lt;/td&gt;
&lt;td&gt;deploys from main; runs on Postgres 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;as_believed&lt;/code&gt; May 1&lt;/td&gt;
&lt;td&gt;deploys from main; deploys from trunk; runs on Postgres 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;as_of&lt;/code&gt; May 1 and &lt;code&gt;as_believed&lt;/code&gt; May 1&lt;/td&gt;
&lt;td&gt;deploys from trunk; runs on Postgres 15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows are worth reading slowly. "Runs on Postgres 15" is missing from the &lt;code&gt;as_of&lt;/code&gt; March 1 row because it was never true on March 1 or any other date; it comes back only when you ask what was believed, which the "point-in-time belief query includes since-corrected facts" test pins. And &lt;code&gt;as_believed&lt;/code&gt; alone ignores the world axis, which is why it still lists "deploys from main": pass both parameters to ask what was believed and true on the same day. A test named "as_of and as_believed together apply BOTH axes" pins the last row of the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is old, and Postgres doesn't do it for you
&lt;/h2&gt;

&lt;p&gt;None of this is new. The temporal-database community's &lt;a href="https://infolab.usc.edu/csci599/Fall2001/paper/glossary.pdf" rel="noopener noreferrer"&gt;consensus glossary&lt;/a&gt; (the 1998 version) defines both timelines: valid time is "usually supplied by the user", transaction time is "system-generated and -supplied", and past transaction times "cannot be changed". Snodgrass's &lt;a href="https://rts.cs.arizona.edu/tdbbook.pdf" rel="noopener noreferrer"&gt;book on time-oriented SQL&lt;/a&gt; (Morgan Kaufmann, ©2000, freely available from the author's site) describes the split this post is about: archival rows "originate from two separate processes", an end time that later "becomes known", and the case "when erroneous data was discovered and corrected; the data in error is retained" (p. 338). Fowler's &lt;a href="https://martinfowler.com/articles/bitemporal-history.html" rel="noopener noreferrer"&gt;2021 article&lt;/a&gt; calls the two timelines actual history and record history, and says "record history itself is append only".&lt;/p&gt;

&lt;p&gt;SQL:2011 standardized the pieces, as &lt;a href="https://sigmodrecord.org/publications/sigmodRecord/1209/pdfs/07.industry.kulkarni.pdf" rel="noopener noreferrer"&gt;Kulkarni and Michels describe&lt;/a&gt;: system-versioned tables for transaction time and application-time period tables for valid time. It has no term for a table that is both; "bitemporal tables" is the paper's word, not the standard's.&lt;/p&gt;

&lt;p&gt;PostgreSQL 16, which lontar runs on, has neither table type: &lt;a href="https://www.postgresql.org/docs/16/unsupported-features-sql-standard.html" rel="noopener noreferrer"&gt;T180 and T181 are on its unsupported-features list&lt;/a&gt;. PostgreSQL 18 added &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; for primary keys and unique constraints and &lt;code&gt;PERIOD&lt;/code&gt; for foreign keys (&lt;a href="https://www.postgresql.org/docs/18/release-18.html" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;), not system-versioned tables. The machinery is yours to build.&lt;/p&gt;

&lt;p&gt;What lontar adds is small. A required discriminator: every revision says &lt;code&gt;ended&lt;/code&gt; or &lt;code&gt;corrected&lt;/code&gt;. A correction's successor inherits both bounds of the original's window, which is my design choice; I haven't found it stated as a rule anywhere. And guards for a writer that retries, which the next three sections cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ended
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ended&lt;/code&gt; is the ordinary case: the fact was true, and the world moved on. The revision sets the fact's &lt;code&gt;valid_to&lt;/code&gt; to &lt;code&gt;at&lt;/code&gt;, which is now if the caller leaves it out, and a replacement, if there is one, becomes a new row that starts at &lt;code&gt;at&lt;/code&gt; and has no end. In the running example, "deploys from main" gets a &lt;code&gt;valid_to&lt;/code&gt; of April 14, and "deploys from trunk" starts the same day.&lt;/p&gt;

&lt;p&gt;Two guards sit in front of that write.&lt;/p&gt;

&lt;p&gt;The first is range. &lt;code&gt;at&lt;/code&gt; comes from a model, so nothing upstream stops it from preceding the fact's own &lt;code&gt;valid_from&lt;/code&gt;. The database &lt;code&gt;CHECK&lt;/code&gt; would catch that, and what the model would get back is an opaque SQL error it can't act on. So the application checks first and returns a typed &lt;code&gt;InvalidTemporalRange&lt;/code&gt; saying &lt;code&gt;valid_to&lt;/code&gt; precedes &lt;code&gt;valid_from&lt;/code&gt;, and the transaction rolls back. The test "refuses to end a fact before it began, with the typed range error" pins it. Ending a fact at exactly its &lt;code&gt;valid_from&lt;/code&gt; is allowed.&lt;/p&gt;

&lt;p&gt;The second is state. A fact that has already ended can't end again: the call fails with &lt;code&gt;FactAlreadyRetired&lt;/code&gt;, whose message ends "re-read current state before revising". It is a different error from "not found" on purpose, because "not found" would send the model hunting for another id when it should re-read. The test "refuses to end a fact that has already ended" pins it.&lt;/p&gt;

&lt;p&gt;The second guard exists because of retries. A model keeps ids it saw in an earlier recall, and &lt;code&gt;revise_fact&lt;/code&gt; is annotated &lt;code&gt;idempotentHint: false&lt;/code&gt;, so a retried call can land on a fact that is already retired. Without the guard, the &lt;code&gt;UPDATE&lt;/code&gt; runs again and a second successor is inserted. The store then holds two current "deploys from trunk" rows, or two contradictory ones if the retry worded the replacement differently, and the next session's recall hands both to the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Corrected, and the window the successor inherits
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;corrected&lt;/code&gt; is the other half, and the tool's own wording for it is "the fact was NEVER true and we were wrong". The revision sets &lt;code&gt;invalidated_at&lt;/code&gt; to &lt;code&gt;at&lt;/code&gt;, which is the belief axis, and leaves &lt;code&gt;valid_to&lt;/code&gt; alone. A replacement, if there is one, is inserted in the same transaction.&lt;/p&gt;

&lt;p&gt;A corrected fact is terminal. Any later revision, of either reason, fails with &lt;code&gt;FactAlreadyRetired&lt;/code&gt; and &lt;code&gt;retiredBy: "corrected"&lt;/code&gt;. The test "a corrected fact is terminal; any later revision fails FactAlreadyRetired" pins it. The guard has to be separate from the one for ended facts, because a corrected fact keeps an empty &lt;code&gt;valid_to&lt;/code&gt;: an &lt;code&gt;ended&lt;/code&gt; revision would walk straight past a &lt;code&gt;valid_to&lt;/code&gt; check, and either reason would insert a second successor beside the first.&lt;/p&gt;

&lt;p&gt;The successor inherits both bounds of the original's window, &lt;code&gt;valid_from&lt;/code&gt; and &lt;code&gt;valid_to&lt;/code&gt;, plus its subject, and &lt;code&gt;supersedes&lt;/code&gt; points back at the original. Both bounds, because of a bug I wrote. My first version carried only &lt;code&gt;valid_from&lt;/code&gt;. Take "deploys from main", ended April 14, and suppose someone corrects its wording to "deploys from the main branch". With only &lt;code&gt;valid_from&lt;/code&gt; carried, the new row has no &lt;code&gt;valid_to&lt;/code&gt;, and an empty &lt;code&gt;valid_to&lt;/code&gt; is exactly what the current filter matches, so the dead branch comes back as current beside "deploys from trunk".&lt;/p&gt;

&lt;p&gt;The suite didn't catch it, and the reason is the instructive part. Every &lt;code&gt;corrected&lt;/code&gt; test started from a fact with no end, so the buggy value and the expected value were both empty. Review caught it before the code first merged. The test "'corrected' carries the original's valid_to to the successor" now starts from an ended fact.&lt;/p&gt;

&lt;p&gt;Correcting an ended fact is legal on purpose. Fixing the wording of something that has already ended is ordinary, and the successor carries the end forward instead of reopening it.&lt;/p&gt;

&lt;p&gt;Because a correction lives on the belief axis, the wrong fact isn't deleted. &lt;code&gt;as_of&lt;/code&gt; never returns it, and &lt;code&gt;as_believed&lt;/code&gt; does, which the remember skill calls "how the record of having been wrong is preserved". In the table under "Two timelines", "runs on Postgres 15" is that row: absent for every world date, present when you ask what was believed on May 1.&lt;/p&gt;

&lt;p&gt;Without terminality, a model that retries a correction, or tries to end a fact it has just corrected, would leave two replacements both current, "runs on Postgres 16" and a second take on it, and two rows that each claim to have replaced the same fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the distinction impossible to skip
&lt;/h2&gt;

&lt;p&gt;A rule is only as good as the paths around it, so the paths are closed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revise_fact&lt;/code&gt; is the only way to retire a fact. &lt;code&gt;remember_fact&lt;/code&gt; inserts and nothing else, and it has no &lt;code&gt;supersedes&lt;/code&gt; parameter, because one would let a caller retire a fact without saying which way, and that is the one distinction the whole model exists to preserve. On &lt;code&gt;revise_fact&lt;/code&gt; the reason is required, and the wire schema accepts exactly &lt;code&gt;corrected&lt;/code&gt; or &lt;code&gt;ended&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Requiring a reason doesn't make a model choose well, so the remember skill gives it a test question: "if someone asked what was true last month, should this fact be in the answer?" Yes means ended, no means corrected. It also has a rule for when the test doesn't settle it: "When you cannot tell which applies, ask. Never guess."&lt;/p&gt;

&lt;p&gt;The retirement and the successor's insert happen in one transaction. The test "revise_fact with a replacement retires and inserts in ONE transaction" breaks the insert with a 501-character replacement and checks that the original is still current, so the retirement rolled back with it.&lt;/p&gt;

&lt;p&gt;Two sessions can race. Each Claude Code session runs its own MCP server against the same database, and the target row is read with &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;. Without that lock, both revisions would pass the guards and both would insert a successor. The test "concurrent revisions of one fact leave exactly one successor" holds the row, parks both revisions behind it, and expects one &lt;code&gt;accepted&lt;/code&gt; and one &lt;code&gt;FactAlreadyRetired&lt;/code&gt;. With the lock removed, it fails with both accepted.&lt;/p&gt;

&lt;p&gt;Spaces get the same treatment. The lookup is scoped to the session's write space, so a work session can't even see a personal fact to revise ("a fact cannot supersede a fact in another space"), and a composite foreign key from &lt;code&gt;(supersedes, space_id)&lt;/code&gt; to &lt;code&gt;(id, space_id)&lt;/code&gt; rejects it in the schema ("refuses a fact superseding a fact in another space", a raw insert).&lt;/p&gt;

&lt;p&gt;For a retrying writer, that adds up to one thing: a duplicate, late or crossed revision ends as a typed refusal, and the store never holds two answers to what is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bends and leaves out
&lt;/h2&gt;

&lt;p&gt;This is where the model bends, and what it leaves out.&lt;/p&gt;

&lt;p&gt;"Current" means the end of the chain, not "true now". The filter is &lt;code&gt;invalidated_at IS NULL AND valid_to IS NULL&lt;/code&gt;, with no comparison against &lt;code&gt;valid_from&lt;/code&gt;, so a fact dated for next month counts as current today.&lt;/p&gt;

&lt;p&gt;The correction time is the caller's. SQL:2011 makes system-time columns &lt;code&gt;GENERATED ALWAYS&lt;/code&gt;, and users "are not allowed to assign or change" them. Here the database assigns &lt;code&gt;recorded_at&lt;/code&gt;, but &lt;code&gt;invalidated_at&lt;/code&gt; is whatever &lt;code&gt;at&lt;/code&gt; the caller passes, and the test "does not range-check a 'corrected' revision" pins that nothing checks it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ended&lt;/code&gt; is an in-place update. In Snodgrass's first process the previous row moves to the archival store when an end time becomes known. Mine overwrites &lt;code&gt;valid_to&lt;/code&gt;, so only corrections leave a trace on the belief axis. Ask what the store believed on March 1 about May 1, and "deploys from main" is missing, because its end is now written into the only row it has. No test pins this, or the future-dated case above.&lt;/p&gt;

&lt;p&gt;There are no period keys and no overlap checks. Nothing stops two current facts about one subject from disagreeing, and PostgreSQL 16 has no &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; to ask for; 18 added it for keys. Keeping facts from colliding is the writer's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stands
&lt;/h2&gt;

&lt;p&gt;Everything above is in the code today, and it runs for one user: me. It is a stdio MCP server over a Postgres I run myself, installed as a Claude Code plugin. The repository is private for now. It will be open source (AGPL-3.0), whether or not a hosted version gets built.&lt;/p&gt;

&lt;p&gt;The hosted version does not exist. Its plan is the same six tools with today's behavior, so these rules would carry over (&lt;strong&gt;designed&lt;/strong&gt;). Whether it gets built depends on a waitlist: if 280 people confirm a signup at &lt;a href="https://lontar.dev/?utm_source=devto" rel="noopener noreferrer"&gt;lontar.dev&lt;/a&gt; before the window closes, I build it, and if not, lontar stays a personal tool and the list is deleted. The page has the date and the details.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, it isn't lontar. Treat the two ways a fact stops being true as two operations, and make the writer say which one. A writer that is often wrong and retries will take the easiest path, so the easiest path has to keep the difference. If you've modeled this another way, or found a case where ended and corrected aren't enough, I would like to compare notes.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>One memory across AI agents: what it actually takes</title>
      <dc:creator>Roni Yusuf</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:21:12 +0000</pubDate>
      <link>https://dev.to/rymanalu/one-memory-across-ai-agents-what-it-actually-takes-347l</link>
      <guid>https://dev.to/rymanalu/one-memory-across-ai-agents-what-it-actually-takes-347l</guid>
      <description>&lt;p&gt;The memory built into a coding agent lives inside that vendor's tools. What I tell one agent stays with that agent, and the next one I open in the same project starts from nothing. A checked-in instruction file gets part of the way, since any agent can read the repository, but it belongs to the repository: it can't hold what spans projects, or what I wouldn't commit.&lt;/p&gt;

&lt;p&gt;So I built a memory that sits outside the agents: a Postgres store behind six MCP tools, &lt;code&gt;remember_fact&lt;/code&gt;, &lt;code&gt;remember_note&lt;/code&gt;, &lt;code&gt;recall_facts&lt;/code&gt;, &lt;code&gt;search_notes&lt;/code&gt;, &lt;code&gt;revise_fact&lt;/code&gt; and &lt;code&gt;forget&lt;/code&gt;. It holds facts, which record both when something was true and when I believed it, and notes, which are searchable text. Today it runs locally, for one user, as a Claude Code plugin.&lt;/p&gt;

&lt;p&gt;The tools were the easy part. The work was in four requirements around them, and the first three fail silently when you get them wrong.&lt;/p&gt;

&lt;p&gt;I'm writing them down because I'm deciding whether to build a hosted version, &lt;a href="https://lontar.dev/?utm_source=devto" rel="noopener noreferrer"&gt;lontar.dev&lt;/a&gt;, and because they hold no matter who builds it.&lt;/p&gt;

&lt;p&gt;Each claim below says how I know it: &lt;strong&gt;built&lt;/strong&gt; (in the code today), &lt;strong&gt;measured&lt;/strong&gt; (by me, with the Claude Code version named where it matters), &lt;strong&gt;found&lt;/strong&gt; (in Claude Code's shipped binary), &lt;strong&gt;documented&lt;/strong&gt; (in a vendor's documentation, untested unless also marked measured) or &lt;strong&gt;designed&lt;/strong&gt; (written down, not built).&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory must know where you are, and the model must not be the one to say
&lt;/h2&gt;

&lt;p&gt;Most of what I want an agent to remember belongs to one context. A client's deployment quirks belong with that client's repositories; how I like commit messages written belongs everywhere. So the store is divided into spaces, and the first design question was who picks the space for each read and write.&lt;/p&gt;

&lt;p&gt;The obvious answer is a &lt;code&gt;space&lt;/code&gt; parameter on every tool, and I didn't add one. A parameter the model fills in is a parameter any text in its context can argue for: a README, a web page, another tool's output. lontar's tools can't name a space, so a model can't ask for another one. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead the server decides once, at startup, from where it was started. Claude Code starts each session's MCP server in that session's working directory (&lt;strong&gt;measured&lt;/strong&gt; on 2.1.286, and on 2.1.263 before it), and a config file maps directories to spaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;default_space&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"personal"&lt;/span&gt;

&lt;span class="nn"&gt;[[space]]&lt;/span&gt;
&lt;span class="py"&gt;id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"personal"&lt;/span&gt;
&lt;span class="py"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"~/dotfiles"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[[space]]&lt;/span&gt;
&lt;span class="py"&gt;id&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"work"&lt;/span&gt;
&lt;span class="py"&gt;paths&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"~/codes/acme-*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;reads_from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"personal"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolution runs in a fixed order: the &lt;code&gt;LONTAR_SPACE&lt;/code&gt; environment variable if it is set, then the longest path in the file that matches the working directory, then &lt;code&gt;default_space&lt;/code&gt;, which must be named explicitly. A path matches the directory itself or anything beneath it, and a trailing &lt;code&gt;*&lt;/code&gt; matches by plain prefix, so &lt;code&gt;~/codes/acme-*&lt;/code&gt; covers &lt;code&gt;~/codes/acme-api&lt;/code&gt; and &lt;code&gt;~/codes/acme-web&lt;/code&gt;. &lt;code&gt;reads_from&lt;/code&gt; widens reads in one direction only: here, work sessions see personal facts and personal sessions never see work ones. Every write, revision and deletion goes to the session's own space. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two spaces claiming the same directory at the same depth is an error, not a guess: &lt;code&gt;matches "work" and "personal" equally; make one path more specific&lt;/code&gt;. Picking one silently would hide the loser's memories and send every write to the winner. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The failure that stays silent is the boring one. Rename or move a directory and its entry stops matching. Nothing errors; the session falls through to &lt;code&gt;default_space&lt;/code&gt;. If the default happens to be the space that directory belonged to, everything looks fine for a long time while the config names a path that no longer exists. If it isn't, the agent quietly reads and writes the wrong space. lontar doesn't check configured paths against the disk yet, so the fix today is discipline: update the paths when you rename. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory must be there before the first prompt
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;recall_facts&lt;/code&gt; is a tool, and a tool is something the model decides to call. At the start of a session it has the least reason to, because it doesn't yet know what it's missing. So in Claude Code the current facts arrive before the first prompt, through a SessionStart hook: no tool call, and nothing for the model to decide. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The hook runs &lt;code&gt;lontar recall&lt;/code&gt; in the directory named in the hook's input, not in its own working directory, and that command resolves the space through the same code as the server, so the boundary between spaces lives in one place. It fires on &lt;code&gt;startup&lt;/code&gt; and &lt;code&gt;clear&lt;/code&gt; only; after &lt;code&gt;compact&lt;/code&gt;, a wall of facts mid-session is interruption, not context. It also blocks session start, so it has a budget of 800 ms and a hard timeout of 5 seconds. On my machine it takes 656 to 799 ms over five runs (&lt;strong&gt;measured&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;Every failure exits 0 and prints nothing: Postgres down, &lt;code&gt;node&lt;/code&gt; or &lt;code&gt;jq&lt;/code&gt; missing, no config, timeout. The hook runs at the start of every session in every space. A memory that sometimes forgets is an annoyance; one that blocks unrelated work gets uninstalled. The price is in the hook's own comment: when it is broken, a session simply has no memory, which looks exactly like an empty space. &lt;strong&gt;Built.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The output shape is where that price comes due. Claude Code reads the context from a nested field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"additionalContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested three shapes on Claude Code 2.1.286, each in a fresh headless session with a marker string (&lt;strong&gt;measured&lt;/strong&gt;). The nested shape arrived, labeled as hook context. Plain text on stdout also arrived, as a hook success message. A flat &lt;code&gt;{"additionalContext": "…"}&lt;/code&gt; is valid JSON, the hook exits 0, and the model saw nothing. Pair that with a hook that is silent by design, and you have a memory that never worked and never once complained.&lt;/p&gt;

&lt;p&gt;The plugin as a whole can fail the same way. Some time after I moved the repository, the plugin stopped loading, because its marketplace entry still pointed at the old path, and sessions started with no memory and no memory tools. Nothing said so. I found it while checking something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  It has to fit
&lt;/h2&gt;

&lt;p&gt;A hook can't inject unlimited context. Claude Code's hooks documentation states the cap: a hook's &lt;code&gt;additionalContext&lt;/code&gt;, and its plain stdout, are each limited to 10,000 characters. I measured it before I found that sentence, with a hook that injects exactly N characters of numbered 50-character lines, one fresh headless session per size, and the model asked for the first and last line it could see:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Injected characters&lt;/th&gt;
&lt;th&gt;What the model saw&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;all 200 lines&lt;/td&gt;
&lt;td&gt;2.1.268, 2.1.285, 2.1.286&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,001&lt;/td&gt;
&lt;td&gt;the first 40 lines, then "Output too large (9.8KB). Full output saved to: …"&lt;/td&gt;
&lt;td&gt;2.1.285, 2.1.286&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,050 to 19,999 (six sizes)&lt;/td&gt;
&lt;td&gt;the same 40-line preview&lt;/td&gt;
&lt;td&gt;2.1.285&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The documentation and the measurement agree to the character (&lt;strong&gt;documented&lt;/strong&gt;, &lt;strong&gt;measured&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;What happens above the cap is the part worth reading twice. The output goes to a file, and the model gets the path and a preview of the first 2,000 characters. In the documentation's words, "Claude Code doesn't ask Claude to read the file." When I checked on 2.1.266 to 2.1.268, injected context didn't show in the transcript view either, so the person at the keyboard saw nothing. Past 10,000 characters, whether the rest of the memory reaches the model is the model's decision again, which is exactly what the hook was there to take away.&lt;/p&gt;

&lt;p&gt;A memory store reaches the cap sooner than its own limits suggest. &lt;code&gt;recall_facts&lt;/code&gt; stops at 200 facts or about 10,000 tokens, but the token count is estimated from the statements alone, and each fact renders with about 97 more characters of metadata: a subject, an id, a date and a source. At 150-character statements, 40 facts fit in 10,000 characters, a full recall is about 49,000, and the preview keeps the newest eight. The line saying the recall was capped comes last, so it is the first thing cut. That is arithmetic on the renderer, not something I have hit yet.&lt;/p&gt;

&lt;p&gt;There is a third number. The Claude Code binary carries a table of limits, &lt;code&gt;reason:2000, stopReason:2000, systemMessage:4000, additionalContext:8000, permissionDecisionReason:2000&lt;/code&gt;, and the &lt;code&gt;additionalContext:8000&lt;/code&gt; entry is there in 2.1.268 and still in 2.1.286 (&lt;strong&gt;found&lt;/strong&gt;). A 10,000-character injection arrived whole, so it isn't the SessionStart cutoff, and I don't know what it governs.&lt;/p&gt;

&lt;p&gt;The hosted design caps the injection at 8,000 characters, under both numbers, rendered on the server. Facts about the current workspace come first, then facts with no subject, then the rest newest first, and when anything is cut the injection ends with a line inside the budget: "N more facts in lontar; call recall_facts to see them" (&lt;strong&gt;designed&lt;/strong&gt;). The local hook has no cap of its own (&lt;strong&gt;built&lt;/strong&gt;). A space that outgrows 10,000 characters keeps working, quietly, as a 2,000-character preview of its newest facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What another agent must supply
&lt;/h2&gt;

&lt;p&gt;The six tools are plain MCP. Their schemas and behavior don't depend on the client, with one exception: the server tags every write &lt;code&gt;source=claude-code&lt;/code&gt;, a constant a second client would have to replace with its own name (&lt;strong&gt;built&lt;/strong&gt;). Everything else that changes from one agent to the next comes down to two questions: how does the agent tell the memory where you are, and can it load memory before the first prompt?&lt;/p&gt;

&lt;p&gt;Cursor is my example of a second agent. Everything below comes from its documentation as I read it on October 1, 2026, and none of it is tested (&lt;strong&gt;documented&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;The workspace signal exists in two forms. Cursor resolves &lt;code&gt;${workspaceFolder}&lt;/code&gt; inside an MCP server's &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt;, so a config can hand the server its workspace explicitly. And it lists MCP &lt;code&gt;roots&lt;/code&gt;, the protocol's own way for a client to report its workspace folders, as supported. What the docs don't say is which directory a stdio server starts in. The local build resolves its space from exactly that, so if it isn't the project, every session lands in &lt;code&gt;default_space&lt;/code&gt;, the same silent failure as a renamed directory, arriving from a different direction.&lt;/p&gt;

&lt;p&gt;Session-start loading exists too, with a caveat. Cursor's &lt;code&gt;sessionStart&lt;/code&gt; hook can return an &lt;code&gt;additional_context&lt;/code&gt; field, documented as "Additional context to add to the conversation's initial system context." The same page says the hook "runs as fire-and-forget; the agent loop does not wait for or enforce a blocking response." Whether a recall that takes most of a second lands before the first prompt is the first thing I would measure.&lt;/p&gt;

&lt;p&gt;Built-in memory needs none of this, because it never leaves the product it is built into. The vendor decides where it is stored, what it is scoped to and when it is written, and the next tool you open doesn't see it. Support for agents beyond Claude Code is planned, with tested setup guides at launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stands
&lt;/h2&gt;

&lt;p&gt;Everything marked &lt;strong&gt;built&lt;/strong&gt; above runs today, for one user: me. It is a stdio MCP server over a Postgres I run myself, installed as a Claude Code plugin, and the repository is private for now. It will be open source (AGPL-3.0), whether or not the hosted version gets built.&lt;/p&gt;

&lt;p&gt;The hosted version does not exist yet. It would be the same six tools over HTTP with a sign-in, and the 8,000-character session-start design above. Whether it gets built depends on a waitlist: if 280 people confirm a signup at &lt;a href="https://lontar.dev/?utm_source=devto" rel="noopener noreferrer"&gt;lontar.dev&lt;/a&gt; before the window closes, I build it, and if not, lontar stays a personal tool and the list is deleted. The page has the date and the details.&lt;/p&gt;

&lt;p&gt;Whichever way that goes, the requirements stand. A memory shared across agents has to know where you are without the model choosing, arrive before the first prompt, fit in what the agent will actually show the model, and get both of those signals from every agent it supports. If you are building any of this, I would like to compare notes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>postgres</category>
      <category>claude</category>
    </item>
  </channel>
</rss>
