A schema-driven, time-aware company memory, reverse-engineered from
gbrain and rebuilt in Go. You define the shape of your knowledge
in one schema.yaml; raw markdown goes into source/; an ingest agent
turns it into schema-shaped pages; a read agent answers questions from them.
Both agents talk to the memory server over MCP with scoped tokens.
┌──────────────── memory server (memctl serve) ─────────────────┐
source/*.md ──scan─▶ manifest.json (pending / updated / ingested / failed / removed) │
│ │
ingest-agent ─MCP─▶ source_pending → source_read → kb_route (laya) → LLM extract │
(write token) │ → kb_resolve → LLM merge + conflicts → kb_put_page ─────────┼─▶ knowledge/<folder>/*.md
│ validate · render · index │ (system of record)
│ self-wire links · timeline │
│ │ │
│ Postgres + pgvector ◀────┘ │
read-agent ──MCP──▶ kb_query / kb_timeline / kb_latest / kb_get_page / kb_neighbors │
(read token) └──────────────────────────────────────────────────────────────┘
▲
end user (CLI, chat, HTTP /ask, OpenAI-compatible /v1/chat/completions)
| Path | What |
|---|---|
cmd/memctl |
server + admin CLI: serve, scan, reindex, token, search, route, stats |
ingest-agent/ |
source → knowledge agent (MCP client, write token) |
read-agent/ |
question-answering agent (MCP client, read-only token) |
internal/schema |
schema.yaml loader/validator |
internal/page |
page model: frontmatter + compiled-truth sections + timeline; render, validate, chunk |
internal/manifest |
ingestion pipeline ledger (manifest.json) |
internal/store |
Postgres/pgvector index + retrieval SQL |
internal/linker |
self-wiring: typed graph edges without LLM calls |
internal/timeparse |
"last week", "latest", "on 2026-09-20" → date window + ordering |
internal/search |
hybrid retrieval pipeline (gbrain's search stack) |
internal/decision |
laya decision engine client, with LLM and lexical fallbacks |
internal/kb |
knowledge-base service: write, index, back-wire, reindex |
internal/mcp |
MCP server (HTTP + stdio), scoped-token auth, tools, client |
internal/llm |
OpenAI-compatible chat / embeddings / rerank client |
example-kb/ |
example schema and raw sources |
docs/ |
gbrain reverse-engineering notes (pipeline, self-wiring, mapping) |
docker compose up -d postgres # pgvector on localhost:5544
docker compose up -d laya # optional: laya decision engine on :8000 (first build is slow)
cp config.example.yaml config.yaml # set llm/embedding endpoints; keys come from env
export OPENAI_API_KEY=… # or whatever api_key_env names
make build
bin/memctl -config config.yaml migrate
bin/memctl -config config.yaml scan # index source/ → manifest.json
bin/memctl -config config.yaml serve & # MCP on http://localhost:8787/mcp
# tokens: scoped by folder and permission; the secret is printed once
INGEST=$(bin/memctl -config config.yaml token create -name ingest -folders '*' -write)
READER=$(bin/memctl -config config.yaml token create -name reader -folders people,meetings,decisions)
MEMORY_TOKEN=$INGEST bin/ingest-agent -config config.yaml # one pass
MEMORY_TOKEN=$READER bin/read-agent -config config.yaml ask "When was the latest meeting with Globex, and what was decided?"To run with no API keys at all, use config.dev.yaml: it has a deterministic
offline hash embedder and no LLM. The server, pipeline and search all work,
though retrieval is lexical-quality. The agents always need an LLM.
<kb>/schema.yaml declares the folders and the exact shape of their pages.
Example: example-kb/schema.yaml.
timezone: Asia/Ulaanbaatar
rules: | # global ingestion rules
- Only record facts stated in the source.
folders:
- name: meetings # → knowledge/meetings/
type: meeting
description: Meetings, calls and syncs … # also what the router reads
slug: "{date}-{title}" # file name template
date_field: date # the page's effective date
recency_halflife_days: 60 # search freshness decay (0 = evergreen)
source_boost: 1.0 # ranking prior
fields:
- {name: attendees, type: links, folder: people, verb: attended}
- {name: status, type: enum, values: [planned, done]}
sections: [Summary, Discussion, Decisions, Action Items]
timeline: false
rules: |
- The date field is the day the meeting happened.
link_verbs: # typed edges inferred from prose
- {verb: reports_to, to: people, patterns: ["reports to"]}Field types: string text date number bool enum list link links. link and
links fields hold page titles or slugs and become typed graph edges.
The server validates every write against the schema. It returns all problems at once, so the agent can fix them in a single retry. It then renders the page deterministically, with sections in schema order.
A page on disk:
---
title: Bob Martinez
type: person
role: VP of Operations
company: Globex
sources: [2026-09-08-globex-kickoff.md, 2026-09-23-globex-update.md]
---
## Summary
Bob Martinez is VP of Operations at Globex (promoted in September 2026) …
## Timeline
- **2026-09-23** | Promoted to VP of Operations at Globex. [source: 2026-09-23-globex-update.md]
- **2026-09-08** | Kickoff call; wants live ETA tracking. [source: 2026-09-08-globex-kickoff.md]Sections are compiled truth: the current state, rewritten as facts change. The timeline is append-only evidence. This is gbrain's two-zone page pattern.
Every page has an effective date: when it is about, not when it was
written. It comes from the folder's date_field, then
date/event_date/published, then a date in the file name, then (for
entity pages) the newest timeline entry. Every timeline line is indexed
separately with its own date.
Questions are parsed deterministically, with no LLM call (memctl timeparse "…"
shows the result):
| Query | Understood as |
|---|---|
| "what happened last week" | window [Mon, Mon) of last week → timeline arm + dated pages |
| "meetings on 2026-09-15" / "on September 15" | that day |
| "in March", "in 2025", "2026-09" | that month / year (future months → last year) |
| "last 10 days", "2 weeks ago", "since 2026-09-01", "from X to Y" | windows |
| "yesterday", "on Monday", "өчигдөр", "өнгөрсөн долоо хоног" | windows (English + Mongolian) |
| "latest meeting with Alice", "most recent …", "сүүлийн уулзалт" | ordering: newest relevant first |
| "first decision about pricing" | ordering: oldest relevant first |
| "recently", "what's new", "currently" | soft recency boost using each folder's half-life |
"Latest meeting with Alice" finds Alice through the title/alias arm. It then
follows her attended edges, and orders only the meetings wired to her by
date. A newer meeting she did not attend never wins.
Tools: kb_query (dates in the question), kb_timeline (what happened in a
window, or one page's history), kb_latest (newest/first pages in a folder,
optionally about an entity), kb_list_pages (exhaustive, date-filtered).
Raw markdown goes in <kb>/source/ (any depth). A scan runs on memctl scan,
and on every source_pending call the ingest agent makes. It hashes each file
and reconciles <kb>/manifest.json:
| status | meaning |
|---|---|
pending |
new, never ingested |
updated |
ingested before, content hash changed since |
ingested |
knowledge is current with this file |
failed |
last attempt errored (the error is stored); retried when the file changes or on source_mark reset |
removed |
file deleted (its pages are kept) |
Pending files are returned oldest document first, so the timeline and the
"current state" are built in chronological order. When a file is marked
ingested, the agent reports the hash it actually read. If the file changed
mid-ingestion, it stays updated rather than silently losing the edit. Raw
sources are also chunked and embedded, so include_sources: true searches
content that has not been ingested yet.
For each pending source the agent:
source_readfetches the content, hash and document date.kb_routeasks laya which folders the source is about. This is used as a hint.- An LLM extraction lists the entities and events in the source, restricted to schema folders; anything outside the schema is dropped.
- For each entity,
kb_resolvefinds an existing page by exact slug, alias or fuzzy title, then falls back to semantic search. Dated folders only match the same date: "Weekly sync" on two days is two pages. - An LLM merge rewrites the compiled-truth sections, appends only new
timeline events, and reports conflicts:
- Newer source contradicts the page → the new value wins, and the old
claim is recorded as
superseded(and stays in the timeline). - Older source → current state is kept; the old fact becomes a dated timeline entry.
- Genuinely contradictory → the current value is kept and the conflict
is recorded as
unresolved. It is also flagged on the page under## Open Questions.
- Newer source contradicts the page → the new value wins, and the old
claim is recorded as
kb_put_pagevalidates, renders, writes and indexes the page. A schema rejection gets one repair round with every error listed.source_markrecords the result: ingested with the pages written, or failed with the error.
Entity pages are written before event pages, so a meeting links to the people
created in the same pass. Useful flags: -watch 60s keeps polling, -file ingests
a single source, and -dry-run renders pages without writing.
read-agent runs an OpenAI-compatible tool-calling loop over the
read-only MCP tools the token allows. It is told to answer only from
retrieved pages, give dates, prefer the newest facts for "current" questions,
and cite [folder/slug].
read-agent ask "what happened last week?"
read-agent chat
read-agent serve -addr :8788 # POST /ask and POST /v1/chat/completionsThe /v1/chat/completions endpoint lets any OpenAI-compatible chat UI use the
knowledge base. Client-supplied system and tool messages are stripped.
memctl token create -name sales-bot -folders companies,people,meetings # read-only
memctl token create -name ingest -folders '*' -write # everything
memctl token create -name meeting-writer -folders meetings,source -write -expires 30d
memctl token list
memctl token revoke -name sales-bot- Folders are schema folders, plus
source(raw sources and the pipeline tools), or*. - Only the SHA-256 of a token is stored. Tokens can expire and be revoked.
- Enforcement happens server-side on every tool:
- Write tools are hidden from read-only tokens.
- Every retrieval arm is filtered to the token's folders.
- Graph edges are returned only when both ends are in scope, so an out-of-scope slug never leaks through a link.
- HTTP uses
Authorization: Bearer <token>. For stdio, runmemctl serve -stdiowithMEMORY_TOKEN, or-trustedfor local admin use.
laya is a non-autoregressive
encoder that answers typed questions (choice, score, yes/no) in one forward
pass, in about 30 ms. It is Python/PyTorch, so it runs as the laya compose
service and is called over POST /v1/systemone.
Routing asks one yes/no question per folder ("does this text belong in
meetings (…description…)?"). A text can therefore land in several folders.
Folders at or above laya.threshold are searched. If routing finds nothing,
the search retries on the whole token scope.
The chain is laya → LLM → lexical, so a missing sidecar costs accuracy, not
availability. Routing is used by kb_query (read agent), kb_route (ingest
agent) and memctl route.
Everything speaks the OpenAI wire format: /chat/completions (tools, JSON
mode), /embeddings, and optionally /rerank (the Jina/Cohere/TEI/vLLM
shape). OpenAI, vLLM, Ollama (http://localhost:11434/v1), LM Studio,
OpenRouter, DeepSeek and MiniMax all work. Reasoning models' <think> blocks
are stripped. Agents can also be configured purely from env:
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY.
Every outbound HTTP integration (llm, embedding, rerank, laya, and the
agents' mcp connection) accepts a tls block. The top-level tls block is
the default; a per-integration block overrides only the fields it sets.
tls: # default for all integrations
ca_file: ./certs/corp-ca.pem # extra root CAs (PEM), on top of the system roots
insecure_skip_verify: false
laya:
tls:
insecure_skip_verify: true # just laya: self-signed, trusted networkMEMORY_CA_FILEandMEMORY_TLS_INSECURE=trueoverride the default from the environment.- A missing or invalid CA file fails at startup and names the integration.
- Disabling verification logs a warning for each integration it affects.
- Postgres TLS is configured in
database_url(sslmode=verify-full&sslrootcert=…).
Knowledge chunks, raw-source chunks and timeline entries are embedded into
vector(dims) columns with HNSW cosine indexes. Filtered queries use pgvector
iterative scans. Full-text search uses the simple config by default, which is
language-neutral and works for Mongolian. The markdown files are the system of
record; memctl reindex [-force] rebuilds the index from them. Tables:
pages, page_aliases, chunks, links, timeline_entries, source_docs,
conflicts, api_tokens.
See docs/GBRAIN_REVERSE_ENGINEERING.md for the full pipeline and the mapping to gbrain's source files. In short:
| Knob | conservative |
balanced (default) |
tokenmax |
|---|---|---|---|
| results | 10 | 20 | 50 |
| token budget | 4 000 | 12 000 | unlimited |
| cross-encoder rerank | off | on (if configured) | on |
| LLM query expansion | off | off | on, budget-weighted (0.5) |
| relational graph arm | off* | on | on |
| graph augment depth | 0 | 0 | 1 |
* The graph arm still runs for relational questions and for "latest/first X with Y" in every mode.
make test # unit tests
make up && make test-integration # Postgres-backed test (disposable DB)
bin/memctl -config config.dev.yaml search -json "latest meeting with Bob" # inspect ranking