3ngram 1.4.1
3ngram v1.4.1
Minor release: structured retrieval. Numbers no longer live only inside prose —
facts get a live write path, search learns an exhaustive chronological mode, and
bi-temporal range reads turn a fact series into one call. No new MCP tool was
added for any of it; every capability extends an existing tool's query shape,
and the tool count stays at eleven.
Structured facts capture
-
rememberacceptsfacts[]— structured facts (subject, predicate,
value, optional validity window) written atomically with the memory, in the
same transaction, composite-FK'd to it. The response echoesfactIdsexactly
when facts were written; existing responses are byte-identical. Both
transports (MCP andPOST /api/v1/memories) carry the same contract. -
The convention is one measure per fact, unit in the predicate: subject
lift.back_squat, predicatetop_set.weight_kg, value98. Values stay
text; charting consumers cast on read. -
Fact timestamps are ISO strings at the boundary. A
Date-typed field
cannot ride the JSON Schema that MCP publishes viatools/list, so the
contract is ISO-only with conversion at the handler seam. Validity windows are
compared as instants, not strings, so mixed-precision pairs
(…00Zvs…00.001Z) behave correctly.
Fact proposals: staged extraction awaiting review
-
A new
fact_proposalstable stages machine-extracted facts for human
review before they become queryable truth. One open proposal per candidate
fact (partial unique index overmd5(value)— btree-safe for long values),
first-proposal-wins collapse semantics, append-and-supersede lifecycle, full
RLS with the tenant-qualified composite FK making cross-tenant references
unrepresentable. -
review_proposalsreviews both kinds. The list grows an optional
factProposalsarray (edge-only responses are byte-identical); accept and
reject take the sameproposalId-only input and probe both proposal kinds.
Accepting a fact proposal materializes the fact in the same transaction;
a concurrent double-apply loses the race cleanly rather than double-writing. -
The extraction job itself is deliberately not in this release; the seam it
will use (an injectable extractor behind the same repository pattern the
consolidator uses) ships ready.
Search: supersession visibility
-
Superseded predecessors are demoted for every revise kind. The ranking
penalty previously keyed onsupersedesedges only;updatesrevises closed
a memory's validity without demoting it. Both now demote. -
Every search hit carries
superseded: boolean, on both transports and
both projections, computed from the same predicate that drives the demotion —
a demoted hit can never readfalse. Superseded rows remain retrievable
(demote, never filter); the flag is what tells you which ones they are.
Search: chronological list mode
-
order: "chronological"turnssearchinto an exhaustive, filter-driven
enumeration: no query needed (filters required instead), no embedding call, no
semantic ranking — newest-recorded first, live memories only, withas_of
time travel lifting the live gate when asked. "Every gym session, in order"
is now a contract the server actually offers rather than a top-k
approximation. -
The list cursor is small and drift-free. Ranked search's continuation
token carries a frozen candidate pool (~4–6 KB); the chronological cursor is a
(recorded_at, id)keyset position. It carries the timestamp at full
microsecond precision as opaque text — a millisecond-floored cursor silently
skips rows when a batch insert shares one transaction timestamp, and a
regression test now pins the exact failure. -
Cursors are bound to the mode and filters that minted them; replaying a
cursor across modes is rejected as a typed mismatch, never silently misread.
Facts: bi-temporal range reads
-
get_factsacceptsfrom/to— a half-open[from, to)overlap window
over fact validity, ordered chronologically, returning every generation in the
window (superseded ones included: that is what a history is).recordedAt
joins the output so consumers can tell when a fact was observed versus when it
was written down — retroactive logging stops lying about dates. -
Range and
as_ofare mutually exclusive; empty and inverted ranges are
rejected; bounds reject sub-millisecond precision and advertise the limit.
Also in this release
GET /api/v1/version(authenticated) reports the running server version, so
deploy tooling can tell a finished rollout from one still in flight.- MCP reference documentation for resources and the tool surface expanded, and
the design docs now record the deliberate decision to keep the twelfth tool
slot unspent.
Review hardening
Pre-release review (v1.4.0 was versioned but never tagged; this release
supersedes it) closed five findings before anything shipped:
- Account erasure and portability export now cover
fact_proposals—
staged proposal content is redacted on erasure and included in the export
archive like every other user-owned table. - Chronological search rejects a
queryrather than silently ignoring it —
the query never applied to the enumeration, so a caller could receive the
entire live set believing it was filtered. - Fact write timestamps reject sub-millisecond precision (the read bounds
already did), so nothing is silently truncated on the way in. - The
supersededflag and its ranking demotion now require closed validity
in addition to an incoming revision edge, so an importedupdatesedge whose
target is still live is neither flagged nor demoted — search now agrees with
memory_historyabout what "superseded" means.
Upgrading
- One additive migration (
0031_fact_proposals) — a new table; no existing
table is altered. The migration adds a foreign key referencingmemories, so
apply it with alock_timeoutand retry rather than queueing behind
long-running transactions. Self-hosters on FIPS-mode Postgres builds: the new
unique index usesmd5(), which FIPS disables. - No breaking API changes. All new request fields are optional; all new response
fields are additive. Shipped schemas are byte-identical for existing calls.
Immutable container reference
ghcr.io/b3dmar/3ngram@sha256:932e54b6e8cabe1c2ee3fd1dea225b57def47b64017e277bb608e8a2d4248c2e