fractal-memory
v0.1.0
Published
Hierarchical-graph long-term memory for AI agents: variable-depth abstraction tree, cross-topic recall, temporal knowledge graph. Ships an MCP server, an HTTP service, and a LangGraph BaseStore.
Maintainers
Readme
Variable-depth abstraction hierarchy with top-down beam search, O(1) staging buffer for adds, and a local multilingual semantic embedder. Drop-in plugin for OpenClaw.
Core idea
Every internal node is the abstraction (medoid) of the cluster of nodes beneath it. Depth isn't fixed — it emerges from the data: a dense branch goes deeper, a simple one flattens out sooner. The model sees two verbs:
mem.add("text") // O(1) — buffer, no rebuild
mem.recall("query") // O(log N) — top-down beam searchInternal navigation, clustering, rebuilding: all hidden. The model never touches nodes directly.
Structure
src/
engine.ts — HGraphMemory: core engine, math, buffer, persistence, usage weight
temporal.ts — TemporalMemory: bi-temporal validity, contradictions, entity graph, evidence, dismiss
decisions.ts — DecisionMemory: reasoning as nodes — premises, causal edges, verdicts, rewrite
fractal.ts — node card (memory_describe): level, themes, entities, chat, media
bundle.ts — .fractal export/import format (gzip+JSON, media embedding)
ingest.ts — PDF pipeline: extraction + table linearization + semantic chunking
decompose.ts — splits raw text into atomic propositions (memory_decompose)
multimodal.ts — CLIP embedder + cross-modal add/recall (images as concepts)
image-index.ts — dedicated HGraphMemory in CLIP-space (separate from text embeddings)
media-embed.ts — reads a local image off disk, embeds it as an MCP image block
media-mime.ts — file-extension → MIME/modality mapping
transcribe.ts — ffmpeg + local Whisper, audio/video → caption
embed-local.ts — local semantic embedder (Transformers.js) + remote/hash cascade
embed-static.ts — model2vec static embedder (table lookup, no forward pass); NOT
wired into makeEmbedder — measured and rejected, see below
origine.ts — stated by someone vs derived by us: the one place that decides
titolare.ts — who answers for the data: read from the environment, never
the source, because it differs per installation
bundle.ts — .fractal export/import, and the per-type routing that keeps
an episode from coming back as a permanent fact
embed-service.ts — standalone e5 HTTP service, shared across processes
server.ts — MCP server (stdio): 4 memory types, 26 tools — client-agnostic
http-server.ts — multi-user HTTP service: MCP, REST, web site, accounts (see below)
cluster-server.ts — optional multi-process front (npm run serve:cluster): sticky
routing by account/org so no worker forks a shared tree, pinned
worker for site/signup/webhook traffic; see §Multi-user service
mcp-proxy.ts — stdio→HTTP bridge for clients that don't speak remote MCP well
accounts.ts — email+password accounts, sessions, one-time tokens, plan
org.ts — company mode: department tree, prefix visibility, activity log
marketplace.ts — publish/browse/rate shared memories, PII wall
skills.ts — Agent Skills authored by hand: real SKILL.md, per-account
agents.ts — N agents per account, each with its OWN tree, plus the grants
offers.ts — hand-off between agents: an address and a hint, never a write
trace.ts — the wire: who talked to whom, observed here or reported from outside
attesa.ts — "what's waiting for you", DERIVED from open hand-offs and the
wire — never a list anyone maintains, so it can't go stale
chat-import.ts — ChatGPT/Claude/Gemini exports → candidate memories, preview first
esterni.ts — MCP servers a user DECLARES for an agent (never connected to)
storage.ts — every persisted tree is gzipped at rest (4-12x smaller,
measured — grows with tree size), decompressed to RAM on
load, never to a temp file on disk
zip.ts — the ZIP reader, hand-written: EPUB and Office are both ZIPs of XML
epub.ts — a book → chapters, in SPINE order (filenames lie: ch10 sorts before ch2)
office.ts — DOCX/PPTX/XLSX → text: slide order numeric, Excel strings are indices
youtube.ts — a video link → its audio (yt-dlp) → the same Whisper as local media
openapi.ts — the public API document, generated from the tool list, never by hand
agent.ts — a local chat loop over the tools (demo/CLI, not the service)
chat.ts — talking to an AGENT: the tool-calling loop, pure (no HTTP,
no LLM hard-wired) so a fake model can drive it in tests
mailer.ts — transactional email (Resend/Postmark/console), zero deps
billing.ts — Stripe subscriptions over the REST API + webhook signatures
llm.ts — any OpenAI-compatible endpoint (rewrite, synthesis) — always optional
monitor.ts — counters + what actually broke, not just how often
alerting.ts — error-rate watchdog → webhook (Slack/Discord/ntfy)
web-ui.ts — landing/signup/login/dashboard HTML (served by http-server.ts)
visualize.ts / .html — standalone browser visualizer + its own local HTTP API
langgraph-store.ts — FractalStore, a real LangGraph BaseStore
index.ts — OpenClaw plugin shell (register, tool, hooks)
init.ts — one-command MCP config generator for IDEs/agents
hyperbolic.ts — Poincaré-ball router (research code, not on the hot path — see Status)
mod.ts — the public npm entrypoint (fractal-memory)
demo.ts — end-to-end demo
stress.ts — the suite's entrypoint: runs everything in tests/
test-harness.ts — shared check()/summary, so a suite cannot pass silently
tests/ — checks by area: engine, temporal, search, decisions,
accounts, marketplace, fractal, http end-to-end,
storage (compression at rest), sicurezza (the suite
that tries to BREAK IN: isolation, IDOR, traversal,
injection, brute force — see §Security)
scripts/
ingest-obsidian.ts — bulk-ingests a vault of .md notes
crawler.ts — RSS/DDG crawler + optional local-LLM summary → memory
consolidate.ts — clusters → synthesized higher-level facts (LLM, prototype)
promote.ts — recurring episodic → semantic promotion (deterministic + optional LLM)
benchmark-embedders.ts — embedder quality comparison harness
eval/ — the benches: retrieval A/B, mutation, decisions, threshold
calibration, browser tour, load, failure paths (see TESTING.md)
verify.mts — runs every bench, one table, exit code is the verdict
backup.ts — rotating copy of the users directory (systemd-friendly)
setup.sh — one-command bootstrap: Node check, npm ci, ffmpeg, yt-dlp
hardening.sh — the MACHINE's defences (npm run harden): auto security
patches, firewall, fail2ban, SSH keys-only. Separate and
opt-in because it is the only part that can lock you out
openclaw.plugin.json — plugin manifestMCP server (recommended usage)
Client-agnostic: Claude Desktop, Claude Code, any MCP agent.
# no clone needed — npm installs the published package and runs the MCP server on stdio
npx fractal-memory
# or from a clone, which is what you want if you're going to change anything
npm install && npx tsx src/server.tsThe npx line works because the published package ships a bin and a prepare script,
so installing it builds dist/ first. check:architettura verifies all of that against
what the landing page promises — a command advertised on a marketing page that doesn't run
is the first thing a visitor finds out, and they find it out instead of us.
Claude Desktop config (claude_desktop_config.json):
{
"mcpServers": {
"fractal": {
"command": "npx",
"args": ["-y", "fractal-memory"],
"env": { "FRACTAL_DATA_DIR": "/path/to/data" }
}
}
}26 tools exposed (the same 26 on stdio, remote MCP over HTTP, and the proxy — no subset).
Base memory: memory_add, memory_recall, memory_get, memory_pin, memory_delete,
memory_stats, memory_maintain. Temporal: memory_history, memory_asof, memory_correct
(corrects a fact by id, history preserved), memory_dismiss (the user rejects a memory: it stops
surfacing but stays on record as refused, so it isn't simply re-inferred tomorrow). Documents and media: memory_ingest takes a PDF, an EPUB, a Word/PowerPoint/Excel file, plain text or markdown, an image, an audio/video file, or a YouTube link — the format is decided by the BYTES, never the extension, and each kind goes where it belongs: text into the tree, images into the CLIP index (a different vector space — mixing them makes cosines that are noise), speech through Whisper and then into the tree like any other text. memory_ingest_media,
memory_decompose. Transfer: memory_export, memory_import. Fractal: memory_nest
(nests one memory inside another), memory_open (opens the internal world: nested children +
automatic links), and memory_describe (the node's card: level, subtree themes,
entities, context and provenance — see "How it works" below). Cross-modal: memory_add_image
(indexes a local image via CLIP) and memory_recall_image (text query → images by real visual
content, not caption).
memory_recall accepts source to restrict the search to one document (e.g. source: "paper.pdf").
Shared-entity links are always followed beyond the direct match, so relational questions
("what does the person leading project X prefer?") work with no extra options; raise hops: 1-3
for longer chains. Results report type, source page, and score.
The API — described by itself
There was already an API: POST /api/tool/<name>, a key in the header, every tool. What
there wasn't was anything that described it. An API you use by reading somebody else's
source code is, to anyone evaluating the service, indistinguishable from an API that isn't
there.
# what can this key do? (the document is generated, and it is personal)
curl -H "Authorization: Bearer fr_…" https://your-host/api/openapi.json
# call a tool
curl -X POST https://your-host/api/v1/memory_add \
-H "Authorization: Bearer fr_…" -H "Content-Type: application/json" \
-d '{"text":"The yellow forklift leaks oil from the right piston.","type":"semantic"}'
# → {"ok":true,"tool":"memory_add","ms":41,"text":"…","content":[…]}The document is generated, not written, and that is the whole design decision. A
hand-written OpenAPI file is a second copy of the truth: it diverges the first time a
schema changes, and it diverges silently — no test fails when documentation lies. Here
the schemas are the ones the MCP server already registers, read the way a real client
reads them (tools/list) through an in-memory transport. The document and the service
cannot disagree, because they are the same object. check:api proves exactly that: it
compares the generated paths against what the official MCP SDK client sees with the
same key, and requires them to be identical.
The document is personal. An agent whose capabilities were narrowed gets a shorter document, because that is its API. Handing every key the same document would tell a restricted agent it may call routes the service will shut in its face.
What the status codes do and do not promise. 4xx only where the service refuses — 401 no key, 403 a capability that was not granted, 404 no such tool, 400 a broken body. When it is the tool that complains — missing arguments, an id that isn't there — you get 200 with the text inside, because fractal's tools report domain problems in prose, and turning their sentences into status codes would mean guessing by reading Italian. That is less elegant than what you see elsewhere, and it is true. The bench pins it: if that check ever goes red because "it returns 400 now", somebody started guessing.
/api/tool/<name> still works and is unchanged — it is what mcp-proxy.ts speaks and what
is written in configurations that have been running for months. Both routes go through the
same handleTool, so there is no second place where permissions can drift apart, which
is the defect this repository already found once when the department rule lived on one door
and not the other.
Integrations (LangGraph, LangChain, Pydantic AI, MCP, REST)
fractal doesn't lock you into one framework. The same memory is reachable six ways:
- LangGraph (JS/TS) —
FractalStoreis aBaseStore:import { createFractalStore } from "fractal-memory/langgraph"and pass it to.compile({ store }). In-process. - LangChain (Python) —
pip install "fractal-memory-client[langchain]", thenfractal_tools(client)hands the tools to an agent. Talks to the HTTP server. - Pydantic AI (Python) — each fractal agent becomes a
pydantic_ai.Agentwhose tools are bound to its memory and whose system prompt is the role you set in the panel, not a copy in the code. Any provider; a three-agent pipeline and its tests ship with it, and the tests need no provider key. - MCP — stdio or StreamableHTTP server (
/mcp), 26 tools, for Claude or any MCP client. - REST —
POST /api/v1/<tool>, with an OpenAPI document the service generates from its own tools. See The API below. - OpenTelemetry —
POST /api/trace, plus a span exporter for Pydantic AI: agents running outside fractal report who talked to whom, and the panel draws the map. - In-process library (Node/TS) —
import { HGraphMemory, makeEmbedder } from "fractal-memory".
Full guide with working, tested examples: INTEGRATIONS.md.
Quickstart — try it all locally
To connect fractal to Claude Desktop, Claude Code, a local model (Hermes/Llama via Ollama or LM Studio), a remote client, or a custom agent, see INSTALL.md — one section per variant.
npm install # install dependencies
npm run demo # see add, recall, dedup, persist/reload in action
npm run viz /tmp/fractal-demo.json # explore the tree in the browser (http://localhost:7700)Three commands and fractal is running, with a tree you can explore visually.
All commands
| Command | What it does |
|---|---|
| npm run demo | Demo: build, recall, dedup, persistence |
| npm test | Stress suite (517 checks, ~14s; -- --solo <area> for one area) |
| npm run verify | All the test banks in one run, one summary table (add -- --tutto for the slow ones) |
| npm run check:org | Company mode: 6 people, 4 departments, who sees what |
| npm run check:marketplace | Marketplace: publish → browse → download → rate → unpublish |
| npm run check:ui | 5 fake users + every page screenshotted by a real browser |
| npm run eval:mutation | Mutation testing — how much the tests are actually worth |
| npm run check:architettura | The diagrams in ARCHITECTURE.md still match the code |
| npm run check:decisioni | Precedents and the comparison with the present, on both doors |
| npm run check:skills | Agent Skills authored by hand: real format, isolation, path traversal |
| npm run check:skill-drafts | Drafting a skill from a PDF (fake LLM) or a recording: the anti-invention guard, truncation, a dead endpoint |
| npm run check:agenti | Several agents on one account: memories stay isolated; cross-reads and expiring hand-offs only when granted |
| npm run eval:generalizzazione | Does an abstract strategy make a precedent surface for a new case? |
| npm run eval:soglia | Where the analogy threshold should sit, against 1,379 human judgements |
| npm run check:failures | Full disk, corrupt registry, revoked key — the bad-day paths |
| npm run check:synthesis | Does the LLM synthesis invent things? (needs an LLM configured) |
| npm run viz <file.json> | Visualizer in the browser — 4 views, topic navigation, import/export |
| npm run serve | Multi-user MCP HTTP server (http-server.ts, for a remote service) |
| npm run serve:stdio | Stdio MCP server (for local Claude Desktop) |
| npm run embed | Shared e5 embedding service (port 11434) |
| npm run ingest | Ingest an Obsidian vault |
| npm run crawler | RSS/web crawler → memory (LLM summary) |
| npm run crawler:fast | Fast crawler without LLM (volume, for benchmarking) |
| npm run consolidate | Consolidate clusters into synthetic facts (prototype, dry-run) |
| npm run promote | Promote recurring episodes → semantic (prototype, dry-run) |
| npm run benchmark | Embedder quality comparison |
| npm run backup | Rotating backup of the users directory |
Interactive visualizer
npm run viz # MCP server's data dir (multi-memory, default)
npm run viz path/to/data-dir # another data dir
npm run viz path/to/memory.json # a single file (one root)
npm run viz path/to/memory.json 8080 # custom portOpen http://localhost:7700 in the browser. A single, minimal concept map: white nodes on a black background. Each circle is a concept; click it to expand into its sub-concepts, and so on down to individual memories (the small dots). Zero external dependencies: physics and rendering built by hand, works offline too.
The map starts from two top-level nodes and mirrors the MCP server's real memory split (same files as the data dir, FRACTAL_DATA_DIR):
| Node | Contains | Underlying files |
|---|---|---|
| user | the user's memory — opening it reveals facts (semantic) and events (episodic) as sub-concepts | semantic-temporal.json, episodic.json |
| skills | procedural memory — instructions and rules to always follow | procedural.json |
An empty node stays visible (dashed circle). Every operation (pin, delete, nest) is routed to the right type's engine and persisted to its own file; search sweeps across all memories and every result reports its provenance.
| Action | Effect |
|---|---|
| + add (bottom) | write and save a memory — choose user or skills |
| ↑ load · import (bottom) | opens the file picker: pdf/txt/md get ingested, a .fractal file gets imported; or drag the file onto the window |
| ⚙ settings (bottom) | generates the local MCP config and, with a toggle, a key for multi-account remote access |
| click a concept | expands into its sub-concepts |
| click an open concept (hollow circle) | collapses it |
| hover a concept | shows ↓ export: downloads that cluster as .fractal |
| shift+click multiple concepts | adds/removes them from the selection (even across user and skills) — a bottom bar appears with ↓ export selection |
| click a small dot | opens the memory in the panel (text, metadata, pin/delete/edit, fractal inner world) |
| drag a node | moves it freely |
| drag background / scroll | pan and zoom |
| / | jumps to the search bar · esc closes it |
Search: type in the top bar and press enter → the beam navigates the tree, opens the path step by step and highlights the concepts it passes through; everything else dims. Results appear in the panel with relevance; click a result to center it.
Inner world (fractal): from a memory's panel you see its nested sub-memories and its affinity-linked memories, and you can nest new ones with + nest inside here.
Attached media: a node carrying attachments (images, video, audio, documents) shows a small dot on its circle in the map — no need to open it first to find out. Its panel shows a thumbnail for images, a download button, and a remove button for each attachment. + media on a node's card attaches a new reference (local path or URL).
Import / Export
| Action | How |
|---|---|
| Import an external .fractal file | drag&drop onto the window, or ↑ load · import |
| Export the whole memory | export memory link at the bottom right — user and skills together |
| Export a single topic | hover a concept → ↓ export |
| Export multiple topics | shift+click on concepts (even across user and skills) → ↓ export selection in the bottom bar |
| Add from PDF / text | drag&drop the file onto the window, or ↑ load · import (.pdf, .txt, .md, .json) |
The .fractal file is gzip+JSON, text-only with no embeddings (~10 KB / 1000 memories). Every fact carries its source memory type (user/facts, user/events, skills): whoever imports it gets each fact back into the right type, not flattened into one pile — rules stay rules, facts stay facts. Within each type, the engine deduplicates and re-clusters on insert, so imported memories fold themselves in near the semantically similar ones already present (older, type-less .fractal files default to "user/facts" — backward compatible).
Media travel with the export by default: local attachments (images, video, audio, documents up to 25MB) are embedded as bytes in the bundle — not just their path, which would be a dead link and a privacy leak (local username/folder structure) on the receiving end. The exported reference is always just the file name, never the full path. An "include media" toggle (on by default) in the UI, or ?media=0 on the export endpoint, lets you strip them instead: the attachment becomes a placeholder — caption + modality + file name, clearly marked as not included, no bytes, no broken link. On import, embedded bytes are written to <dataDir>/media/<content-hash>.<ext> and the reference is rewritten to that new local path — the attachment stays alive and downloadable on the receiving machine, and re-importing the same bundle never duplicates the file on disk (same content → same hash → same file).
Settings — connecting Claude Desktop / Code / a remote device
The ⚙ settings panel generates two configs, no hand-written JSON needed:
- Local server: the stdio MCP block (
command/args/env) ready forclaude_desktop_config.jsonor.mcp.json, already pointed at the data dir you're viewing. - Remote access (toggle): generates a real key in the same multi-account registry used by
http-server.ts(~/.fractal-users/registry.jsonby default), mapped to this memory. Also shows the command to start the remote server and reminds you it needs to be exposed on the internet (ngrok/Tailscale — seeDEPLOY.md) because custom connectors reach it from Anthropic's cloud, not your local network.
For the OpenClaw plugin:
npm run build
# copy into ~/.openclaw/plugins/fractal/
# in openclaw.json: { "fractal": { "enabled": true } }
openclaw gateway restartMulti-user service — accounts, dashboard, MCP over HTTP
npm run serve (http-server.ts) turns fractal into a service other people can sign up
for, not just a local plugin — the same engine, one process per server, one isolated
memory per account.
What it's for. Three different situations, same code:
- A private server for yourself and a few invited people — set
FRACTAL_SIGNUPS_CLOSED=1, create accounts yourself (see below), everyone connects from wherever they are. - A real public service (the DigitalOcean/cloud case) — leave signups open, point a domain at it, people register from the browser and get their own memory.
- A single droplet backing your own multi-device setup — phone, laptop, work machine all point at the same account with different API keys, so revoking a lost device's key doesn't touch the others.
Accounts vs. API keys — two different questions. An account (email + password,
accounts.ts) answers "who are you": it's what lets a person log into the web
dashboard, prove it's them to change a password or delete data, and manage everything
below. An API key (registry.json) answers "which memory does this MCP/REST
request touch": it's what a client (Claude Desktop, a script, mcp-proxy.ts) actually
carries on every call. One account can hold several keys — one per client — so losing a
laptop means revoking one key, not the whole account. Keys created before this system
existed (via the old /join flow, or by hand in registry.json) keep working with no
account attached; accounts are additive, not a breaking migration.
The web flow (/, /signup, /login, /app, all in web-ui.ts, same black/white
visual language as the visualizer):
/signup— email + password (scrypt-hashed,node:crypto, no dependency added), validated server-side (email shape, 10-char password minimum). A session cookie (HttpOnly,SameSite=Lax,Securewhen the connection is actually HTTPS) logs the account in immediately./app(the dashboard) — memory counters (facts/events/skills/concepts), the list of API keys with create/revoke per key and a ready-to-paste MCP config for whichever one you just created, a password-change form (rotating the password invalidates every other session — the standard response to "I think someone else has my password"), and account deletion, which actually removes the data directory from disk, not just the registry entry.- Revoking a key takes effect on the next request (the registry is reread every
time, not cached) — a device you no longer trust loses access immediately, no
propagation delay. You can only revoke your own keys: the handler checks
registry[key].accountIdagainst the logged-in session before touching anything.
Under the hood, two things that only show up under real multi-user load (found by actually running the service, not from reading the code):
- The embedder is a single shared instance per process (
sharedEmbedder()), not one per session. With the local e5 model (~120MB) loaded per-user, a handful of concurrent accounts on a small droplet would exhaust RAM; a shared instance means the memory cost is O(1) in the number of users, not O(n). - It always tries the real semantic embedder first, falling back to the lexical hash embedder only if that genuinely fails to load (and says so loudly in the logs). Earlier it silently used the hash embedder unless an external embedding API was configured — on a freshly installed server (the normal case) that meant every account got keyword-only search by default, silently, while the product's whole pitch is semantic recall.
Subscriptions (optional). With FRACTAL_STRIPE_SECRET_KEY + FRACTAL_STRIPE_PRICE_ID
set, the dashboard grows a subscription card: free accounts get a memory ceiling
(FRACTAL_FREE_MEMORY_LIMIT, default 500), pro accounts don't. Checkout and the billing
portal are hosted by Stripe — card details never touch this server. Webhooks authenticate
by HMAC signature over the raw body (not a session, not a key), with a replay window,
because that endpoint is the one thing standing between "anyone who knows the URL" and a
free pro subscription. The quota only gates tools that add memories: reading, correcting
and deleting keep working past the ceiling, since locking reads would hold the user's own
data hostage. Leave the variables unset and there is no paywall at all.
Email (FRACTAL_MAIL_PROVIDER: resend | postmark | console) powers address
confirmation and password recovery — implemented against the providers' HTTP APIs with
fetch, no SMTP library, no dependency. The default console writes the message to the
server log instead of sending it: on a private server that's a legitimate way to operate,
and it means the flow is complete and testable with no credentials configured.
Marketplace (/marketplace, marketplace.ts). Accounts can publish a thematic
memory for others to download — and the constraints are the interesting part. Only
scope: "agnostic" bundles are publishable, so "share my memory" is structurally
impossible; a heuristic PII scan (email, phone, IBAN, tax code, card numbers) rejects the
publish with what it found and where; browsing shows counts, size and six-word teasers but
never the contents, because otherwise searching is reading. Everything downloaded
enters as evidenceKind: "inferred" and meta.marketplaceImport, so third-party text
ranks below your own facts and shows a ~inferred tag on every recall — a shared memory
can carry a prompt injection just by being read, and the answer is to treat it as
untrusted data, not to try to parse the attack out of it. Quality is reported twice and
never averaged together: a measured self-consistency score computed at publish time, and
subjective stars + comments from people who downloaded it.
Deploying it — droplet sizing, Caddy/systemd, FRACTAL_PUBLIC_URL, backups
(npm run backup + a systemd timer), email, Stripe, closing signups: see
DEPLOY.md §0. /privacy ships a GDPR-oriented data policy describing
what the software actually does — accurate, but have it reviewed before going public.
Multiple processes (npm run serve:cluster) — measured, not assumed
cluster-server.ts is an optional reverse-proxy front for http-server.ts: it spawns
FRACTAL_WORKERS (default: one per CPU core, clamped 2-8) copies of the real server and
routes each request to one of them. Not node:cluster — that does OS-level
round-robin, and this service keeps each account's memory tree resident in RAM per
process, so two different processes touching the same account would silently create two
diverging copies and the last save to disk would win. Instead it routes by resolved
identity: personal accounts by account id, company accounts by org.id + department (so
colleagues sharing one org folder always land on the same worker), and only for the tool
calls that touch exclusively a single account's own tree (memory_add, memory_recall,
etc. — never memory_share/memory_org*/marketplace, which touch cross-account files).
Everything else — signup, login, the dashboard, the Stripe webhook — is pinned to one
fixed worker, unchanged from today's single-process behavior, because AccountStore and
OrgStore load their JSON once into RAM and blindly overwrite it on save; routing that
traffic across workers reproduced real data loss in testing (two of five concurrent
signups silently lost their key) before the fix pinned it.
Verified with a 10-check suite (npx tsx scripts/eval/cluster.mts, all green): a clean
boot never races the first-run admin key, one account's writes always land on the same
worker, personal accounts genuinely parallelize across workers, two colleagues in the same
org never fork the shared folder even under interleaved concurrent writes, and killing a
non-pinned worker mid-traffic doesn't take the others down and the killed one rejoins with
its data intact.
Honest result: on the small droplet this project actually targets (2-4 vCPU, see
DEPLOY.md §0), clustering does not speed up the ~10-company pilot scenario measured
elsewhere in this doc — it was measurably slower until three real bugs were found and
fixed by load-testing it (a first-boot admin-key race, missing HTTP keep-alive on the
proxy's outbound connections causing connection-churn collapse under concurrency, and cold
per-worker embedders stalling the first burst of real traffic), and even after all three
fixes it's roughly on par with, not faster than, plain npm run serve at this scale. The
reason is the same one noted above: the e5 embedder is one shared instance per process,
so clustering multiplies that heavy resource N times on a machine that doesn't have N
spare cores to give it — on 4 cores, the processes compete more than they parallelize.
npm run serve (single process) remains the right default; serve:cluster is here,
correct, and tested for the day this service needs to survive sustained load past the
~50-60 ops/s single-process ceiling on hardware that actually has the cores to spare.
Talking to an agent (/app/chat/<agent>)
Agent mode gives an account N agents, each with its own memory tree, its own role and
its own capabilities. /app/chat/<agent> is a chat with one of them — you reach it from
that agent's row in the panel, or from its card on the canvas, because the chat is with
that agent, not with the account.
What that buys, concretely: the tools read and write that agent's folder — the same one
its MCP key opens, so anything said in chat is there for its external client on the next
call, and anything the external client wrote is what the chat reads. Its role (the line
you wrote in the panel) becomes the system prompt, so it answers as itself. Its
capabilities gate the tools it is handed — an agent without "le domande sul tempo"
isn't offered memory_history, and is refused if the model asks for it anyway, exactly as
its MCP client would be. And everything it does in chat lands on the wire, so the
panel's traffic and the canvas show it working rather than idle.
The tool list is deliberately short and non-destructive: it reads and adds, it does not
delete, correct or import. Not a security boundary — the person is already authenticated
and can do all of that from the panel — but a model that misreads "drop that thing about
Marco" and calls memory_delete does silent, irreversible damage to data someone trusted
us with. Deleting stays a thing you do while looking at what you delete.
An LLM endpoint is required for this page only (FRACTAL_LLM_URL, or any
OpenAI-compatible URL — Ollama and LM Studio included). Everything else in fractal works
without one, so with none configured the page explains how to turn it on instead of showing
a broken box. With a remote endpoint the memory text the tools return goes into the
prompt and therefore leaves the machine: the page says so above the input, not in the
release notes.
It can overwrite, not just add — with a real, time-limited backup. memory_correct is
in the chat's tool set: an agent that hears "actually it's VS Code now, not Vim" replaces
the fact in place instead of piling up a duplicate. That was always non-destructive —
correct() keeps the old version in history, marked superseded, never deleted — so
excluding it from chat earlier was caution, not necessity. What makes it safe to open up now
is that the backup finally expires: TemporalMemory.purgaSuperate(), run by
memory_maintain, truly removes a superseded fact — off the tree, out of
memory_history — once it has sat unclaimed for FRACTAL_RETENTION_SUPERATE_GIORNI days
(default 14). Two weeks to notice a bad correction and go back to the old one; not forever,
not gone instantly. memory_delete still isn't in the chat's tool set — that one has no
backup at all.
And you can delete from the panel now, not only from an external MCP client. The search
results row on /app (/app/memoria/elimina) is the one place in the whole panel that
calls memory_delete — with a confirmation, because unlike a correction this one has no
undo.
Security
Every user's memory is sealed off from every other one, and the suite proves it by
attacking rather than by asserting configuration: npm test -- --solo sicurezza opens a
second account and tries, with it, to read the first one's memories, revoke its API key,
escape its data folder with ../, inject HTML into the panel, and brute-force its login.
67 checks, against a real server process. It found a real hole while being written — see
CHANGELOG.
| Secret | On disk |
|---|---|
| Passwords | scrypt + per-user salt, timingSafeEqual |
| Session tokens | SHA-256 only; cookie HttpOnly+SameSite=Lax+Secure on HTTPS |
| API keys | SHA-256 only, plus a short preview for the panel — the key itself exists only in the response that creates it |
Isolation is one dataDir per key, every path resolve()d and checked inside its root.
Browser-side: nosniff, X-Frame-Options: DENY, Referrer-Policy, HSTS on HTTPS, and a
CSP with default-src 'self' + frame-ancestors 'none' — strict because nothing loads
from a CDN. CORS is offered only on /api and /mcp, never on the cookie-authenticated
panel.
Two things the application code cannot do for you, both ready-made:
sudo npm run harden # auto security patches, firewall (22/80/443 only), fail2ban,
# SSH keys-only — refuses to disable passwords if you have no
# key installed, because that is the one irreversible stepand encryption at rest — gzip is compression, not encryption, and the process needs plaintext in RAM to search anyway, so the right answer is an encrypted volume (LUKS or your provider's checkbox) plus encrypted backups. Both in DEPLOY.md §4d, along with what is deliberately not there yet (no 2FA, no read audit log).
Embedder
Zero config: uses a lexical hash embedder (no dependencies). For real semantics (multilingual, Italian included):
npm install @huggingface/transformersDownloads multilingual-e5-small (~120MB) once, then runs offline.
Alternatively, configure a remote API (Voyage, OpenAI):
"fractal": {
"embedding": {
"baseUrl": "https://api.voyageai.com/v1",
"apiKey": "...",
"model": "voyage-3"
}
}How it works
Everything attaches to two top-level nodes — the same split you see in
the visualizer (see above): user (facts + events) and skills (rules).
But careful, these are a visual grouping only: the engine has no single
tree with "user"/"skills" at the top. Each memory type (semantic,
episodic, procedural) is an independent HGraphMemory, with its own
root and its own rebalancing — the visualizer just shows them side by side
under the same visual node.
USER (visual grouping — not a real node)
│
├── facts ── root of its own HGraphMemory (semantic)
│ ├── AI/ML
│ │ ├── MoE Architectures ──── [DeepSeek, hybrid attention, mHC residual]
│ │ └── Training ──── [Muon optimizer, Poincaré embeddings]
│ ├── DevOps
│ │ └──────────────────── [Prefect, DuckDB, S3, sqlite-vec]
│ └── Personal ──── [Bologna, ZTL, Octopus, LIRA]
│
└── events ── root of its own HGraphMemory (episodic), same mechanism
SKILLS (visual grouping)
│
└── rules ── root of its own HGraphMemory (procedural)
└── "always answer in Italian" · "no preambles" · …Inside each root, the hierarchy emerges like this: query → root → beam
selects the most relevant branches → drill-down → leaves (raw text) → inject
into the prompt. Depth varies per branch (fractal). Higher nodes are routers;
only leaves hold text — and every router is not a synthetic average:
it's the medoid, i.e. the vector of one of its actual children, the one
with the highest cosine similarity to all the others (medoid() in engine.ts).
An internal node is always a real memory representing the others, never a
made-up point in space.
Evidence and dismissal — not every memory deserves equal trust
Every fact carries an evidenceKind: direct (the user said it), inferred (the
agent deduced it from context), manual (the user wrote or corrected it by hand — the
most trustworthy kind there is), or system (produced by a process — ingest,
consolidation, migration). Without this, a wrong guess behaves exactly like something
the user actually told you, and there's no way to tell them apart later.
What changes because of it:
- Ranking.
inferredstarts at 85% weight; everything else at 100%. At equal semantic relevance, a fact the user actually said outranks one the agent guessed. - Corroboration. Adding the same fact again isn't noise to discard — it's evidence
accumulating.
evidenceCountincrements, and if the new mention is stronger than the old one, the evidence promotes (inferred → direct, never the reverse: once something is confirmed, a later guess doesn't downgrade it). Concretely: the agent infers "probably likes jazz" today; next week the user says it outright — that fact is nowdirect, ranked accordingly, without creating a duplicate. memory_correctalways writesmanual. An explicit correction is the strongest signal there is — the user is saying "no, it's this" — regardless of what the superseded fact's evidence was.- The tag shows up in results (
~inferred) whenever a fact isn'tdirect/manual, so the model sees at a glance which answers it should hold more loosely.
Dismissal (memory_dismiss) is a different axis from deletion. memory_delete
means "this shouldn't exist"; dismissed means "I know about this, and I don't want
it" — the user rejecting a fact or a preference the agent surfaced or inferred. The
memory stays on disk (so it isn't silently re-inferred from the same evidence next
time) but disappears from every path that returns results: direct recall and the
entity-graph traversal (a dismissed fact doesn't reappear as someone else's
"related" neighbor either). restore:true on the same tool undoes it. Typical use:
the agent infers "the user probably prefers tea"; the user says "no, actually coffee" —
dismiss the wrong one, add the right one as direct.
Two axes of time — what was true, and what we knew
validFrom/validUntil say when the fact was true in the world.
recordedAt/supersededAt say when we learned it. They are not the same, and the
difference is not academic: if on 10 March the user says "since January I've been on
Atlas", the fact is valid from January but we have known it since 10 March.
With one axis you can answer "what was true in January?" but not "what did we believe
in January?" — and the second is the one you need to judge a decision taken back then. A
choice made in January could not have accounted for a fact that arrived in March, and
grading it as if it could is both unfair and misleading. This is Semantica's bi-temporal
distinction (valid time / transaction time), which was missing here: validFrom was
overwritten whenever a caller backdated a fact, and the moment we learned it vanished.
tm.recall(q, k, { asOf: T }) // what was TRUE at T
tm.recall(q, k, { knownAt: T }) // what we KNEW at T
tm.recall(q, k, { asOf: T1, knownAt: T2 }) // at T2, what did we believe was true at T1
dm.confronta(id, situazione, { comeSapevamoAllora: true }) // judge it by what we knew thenFiles written before these fields read recordedAt as validFrom and supersededAt as
validUntil — exactly the old behaviour, the two axes collapsed into one.
Decisions — reasoning as nodes, and the comparison with the present
The idea comes from Semantica (MIT): don't
store only the fact, store the decision — a graph node carrying its own rationale,
linked to the decisions that caused it and the entities it touched. Their model is
Decision(category, scenario, reasoning, outcome, confidence, …) plus record_decision
(which links entities via ABOUT edges) and add_causal_relationship with
CAUSED / INFLUENCED / PRECEDENT_FOR. decisions.ts keeps that model, with two
differences that are not cosmetic.
1. Confidence is measured, not declared. In Semantica confidence is a float passed
in by whoever records the decision and never re-checked — you can write 0.95 on a
precedent that has failed every single time it was applied. Here it is computed from how
often the precedent, when re-applied, actually held: (held + 1) / (applied + 2). A
decision that has never been re-applied is worth 0.5 — "unknown" — not 1.0.
2. Retrieval is not comparison. find_similar_decisions answers "which precedent
resembles this case". It does not answer "does that precedent still hold". Those are
different questions: a precedent can match at 99% and rest on a fact that has since been
contradicted. decision_recall compares on three independent axes, because none of
them decides alone:
| axis | kind | what it does | |---|---|---| | semantic | relative | how much the stored scenario resembles what the model sees now. It only ranks — absolute cosine thresholds are unusable (measured on e5: unrelated sentences sit at 0.76–0.82, relevant ones at 0.89–0.91) | | structural | absolute | entity overlap between the decision and the current situation (Jaccard). Zero is zero with any embedder — this is the axis that makes the verdict decidable | | temporal | exact | the premises. The decision recorded which facts it rested on; the temporal layer knows right now which are still valid, which were superseded (and by what), which were dismissed. Not a similarity — a set comparison over ids |
Only the third axis can say "this precedent is stale", and it is the one neither Semantica nor any purely semantic lookup can do. MemHarness gets the same effect with a 7B critic trained with GRPO; here it falls out deterministically from data the temporal layer already keeps.
Every precedent comes back with a verdict, never naked:
applicabile— entities in common and every premise still holds.da-rivedere— the case matches but a premise was replaced. The precedent is shown together with the diff ("«single Postgres node» is now «three-node Postgres cluster»"). This is the most useful case to show the model, not the least.obsoleto— dismissed, or every premise fell with no replacement: there is no ground left to re-decide on. Filtered out. Note the distinction fromda-rivedere: a superseded premise means the ground changed, not that it vanished.non-pertinente— no entity in common. Filtered out.
The rewrite (MemHarness's missing step). MemHarness's loop is
retrieve → critique → rewrite/reject → act, and its central claim is that a memory entry
is not the raw observation — it is an abstract strategy plus the observation it came
from. An episode stored verbatim is only retrieved when something nearly identical
happens again; an abstract strategy is retrieved when the same problem happens again.
The critique here is deterministic (the premise diff). The rewrite can't be: abstracting
is linguistic work and needs a model. MemHarness trains a 7B critic with GRPO, which isn't
replicable here, so decision_record(riscrivi: true) uses llm.ts — any OpenAI-compatible
endpoint — under a rule that makes the step safe even when the model is wrong:
A rewrite may only REMOVE, never ADD. If the strategy names an entity or a number that wasn't in the observation, it isn't an abstraction — it's an invention, and it is rejected (the "reject" of their loop).
That check (inventa()) is a pure function: no network, no model, fully testable. It is
why the step can be switched on without anyone having to trust the provider. The rejected
text is kept, with what it invented, because that's the only way to notice a model failing
systematically. If the endpoint is down the decision is still recorded — losing a precedent
because an external service didn't answer would be the worse of the two outcomes.
The guard flags three shapes: proper nouns (capitalized, not sentence-initial),
numbers (anywhere), and code-like tokens — snake_case, camelCase, identifiers
containing digits, backticked spans. That third category exists because of a measurement,
not a hunch: asked deliberately to invent, llama-3.3-70b produced "…in particular the
to_tsvector function, configuring the default_text_search_config parameter…" — all
lowercase, so a guard watching only capitals and numbers saw nothing, and a rewrite
full of precise, verifiable, false detail would have entered memory as a rule. Function
and parameter names are exactly what a model adds to sound competent, and they are the
most dangerous kind of invention.
Measured against a real model (npm run check:riscrittura, llama-3.3-70b via NVIDIA):
of 4 abstractable cases, 3 produced a strategy and 3/3 were judged genuine abstractions
that preserved the useful core; 0 were rejected by the guard (no false positives); both
non-generalizable trap cases were handled without inventing. The counter-test — asking the
model to invent on purpose — is caught in full: Elasticsearch, 1000, pg_trgm, to_tsvector.
The one missing case was an endpoint timeout, not a code failure, and the bench reports
that as inconclusive rather than red: a free shared endpoint hitting ResourceExhausted
is a fact about the world, and a red there would only teach people to ignore the bench.
Known limit, stated rather than hidden: a capitalized word at the start of a sentence is not treated as a proper noun ("Conviene…" opens a sentence, it doesn't name anything), so an invented name placed exactly there, in prose form, and never repeated slips through. Numbers and code-like tokens are checked everywhere. Flagging sentence-initial capitals instead rejected every correct rewrite, and a guard that rejects everything gets switched off, so it protects nothing.
The same guard now covers scripts/promote.ts, where --apply rewrites instead of copying
the medoid verbatim. The verbatim copy looked like the prudent choice — no generated text,
no hallucination possible — and was the wrong one: the promoted fact was near-identical to
an episode still in memory, so the temporal dedup at 0.97 recognised it as a duplicate
and the promotion incremented an episode's evidenceCount instead of producing a new fact.
Consolidation consolidated nothing, silently. --verbatim restores the old behaviour for
anyone without an LLM, and says out loud that the duplicate comes back with it.
Storing the strategy is not enough to retrieve it — the part that surprised us. The
whole point of the abstract rewrite is that the precedent surfaces when the same problem
recurs in a different case. Measured, it doesn't: npm run eval:generalizzazione scores
0/6 with the strategy stored, exactly as it scores 0/6 without it. Retrieval was never
the problem — the raw recall finds the right precedent at 0.900. The entity axis then
discards it, because a new case with the same problem has different entities by
construction. The axis that makes the verdict decidable is the same one that blocks
generalization. decision_recall(generalizza: true) opens a fourth path: no shared
entities, but an accepted strategy and a true cosine against the strategy text —
computed on purpose, because the score recall returns is a normalised rank under RRF and
reads 0.900 for a relevant query and for one about office plants. That takes it to
6/6 with zero false positives, and it ships off by default because the gate is a
0.04 margin whose calibration is measured and unsatisfying — the numbers are
further down.
The return leg. decision_outcome records what actually happened. If the model chose
differently, the new choice is recorded as a divergence: a new decision linked to the
old one by PRECEDENT_FOR. The wrong precedent is not deleted — it stays, with its
confidence dropping, so next time it shows up already weakened, and the lesson learned is
proposed first. (Getting this right required turning off the temporal contradiction rule
inside the decision store: between facts, two similar-but-different sentences are a
contradiction and the new one expires the old; between decisions they are a precedent
and the lesson that followed, and both are needed.)
Three MCP tools: decision_record, decision_recall, decision_outcome. Premises are
referenced as type:id (semantic:l4, episodic:l2) — bare ids are not enough,
they are only unique within one archive and l1 exists in all three.
Architecture — what actually runs
Full version, with diagrams, in ARCHITECTURE.md: the module graph and six pipelines — what actually happens on a write, on a read, when a decision is recorded, when one is recalled, when episodes consolidate, and when a skill is drafted from a recording or a book. It lives in its own file rather than here because it answers a different question from "what is this and should I use it", and because it is long enough to bury everything after it.
It is not prose that drifts: npm run check:architettura (50 checks) fails if a box stops
being a file, an arrow stops being a real import, a lazy path becomes static, a symbol
named in a pipeline no longer exists — or if this README starts keeping its own copy of the
diagrams instead of linking to them.
The shape of the thing, in one paragraph. Five entry points — MCP over stdio, MCP over
HTTP, a REST API, an in-process library, a tree explorer — over one engine. engine.ts
holds the tree, the beam descent and hybrid retrieval; temporal.ts wraps it with validity,
contradictions and the entity graph; decisions.ts wraps that with reasoning nodes. The
engine knows nothing about accounts, companies, marketplaces or HTTP, and the service layer
knows nothing about tree geometry. That boundary is why company mode could be added without
touching engine.ts, and it is the property worth protecting in any future change.
skills.ts sits deliberately outside that stack — a Skill is a file a person writes for
an agent to read, not a fact recalled by relevance, so it never touches the engine at all.
One asymmetry worth knowing about. MCP_TOOL_NAMES is a shared array of 35 tool names,
but only buildOrgMcpServer loops over it — buildMcpServer (personal) and server.ts
(stdio) register theirs by hand. Three doors, three numbers, by what each door knows
about: stdio is single-user, so it gets 26. Personal accounts get 31 — the same 26 plus
memory_ask_agent, memory_ask_agents and the three memory_offer*, all meaningless
without an account to hold several agents. Company accounts get 35 (memory_share,
memory_org, memory_org_activity and memory_ask_depts on top of the personal 31).
That is intended, but "single source of truth for both builders" would be an
overstatement: it is the source of truth for the company door only. A test pins the
stdio count at MCP_TOOL_COUNT - 5 (26) as a difference from the personal count, not
a hardcoded number — so adding a tool to one door and not the other stays red instead of
the two counts quietly drifting apart again.
What is left — as commands you can run
Every item below is either a command or a decision. Where it is a command, it is written
out: clone, npm install, run it. Where it needs a credential or a person, it says so.
Run these locally — they work today, they just need a key or a model
| Command | Needs | What it tells you |
|---|---|---|
| npm run verify | nothing | 20 benches, 1408 checks, ~1.5-2.5 min. The default gate: types, 517 unit checks, payments, company mode, marketplace, architecture, failure paths, decisions, skills, drafts, agents, the wire, the docs pages, the API, knowledge ingest (PDF/EPUB/text/YouTube), effectiveness, UI, Pydantic AI (19 benches / 1376 checks without a Python environment) |
| npm run verify -- --tutto | nothing | 24 benches, 1449 checks, ~7 min: adds promotion, recall quality, mutation testing, the load test and the Pydantic AI integration (23 benches / 1417 checks without a Python environment — see INTEGRATIONS.md). This is the one to run before believing anything |
| npm run eval:soglia | e5 | the analogy gate against 1,379 human similarity judgements. Downloads the STS Benchmark once, caches it under .eval-cache/ |
| npm run eval:generalizzazione | e5 — npm run embed in another terminal | the A/B behind MemHarness's central claim. Currently A 0/6, B 0/6, G 6/6 with 0 false positives — see below, the number has a caveat |
| npm run check:riscrittura | FRACTAL_LLM_URL + FRACTAL_LLM_KEY | rewrite quality against a real model. Measured on llama-3.3-70b: 3/3 genuine abstractions, 0 guard false positives |
| Drafting a skill by speaking (/app/skills/record) | FRACTAL_LLM_URL + ffmpeg on the server | the one path with no end-to-end number: check:skill-drafts covers everything except real speech through Whisper, which no fast bench can manufacture. Record yourself explaining a task and see whether the draft is worth editing |
| npm run check:evals | nothing for half of it; FRACTAL_PA_MODEL + a provider key for the other | are the agents any good? Half A needs no key and produces a real number every run: what the tool listing costs in context with all capabilities against only the ones the agent needs — 41% of the listing, ~2,200 tokens on every single call. Half B is the part that can fail the product: same questions, same model, with and without fractal, and it is written so it can come back negative |
| npm run check:claude | ANTHROPIC_API_KEY | a real model deciding on its own when to save and when to search. A few cents on Haiku. Never run — the one bench with no result yet |
| npm run check:stripe | a real sk_test_… | request shapes against real Stripe. The full cycle is tested against a fake one, which is not the same thing |
| npm run eval:relative | e5 | why relative:true is not the default: single-hop holds 10/10, multi-hop collapses 8/16 → 1/16 |
| npm run eval:descent / eval:hotlane | e5 | tree geometry and the descent's ceiling at 150 / 1k / 5k leaves |
Feed it your own memories — the measurement nobody has
Every number in this repo is on synthetic corpora or on prose scraped from these .md
files. Whether it is useful holding someone's actual life is unmeasured.
npm run embed # terminal 1: the e5 embedder, shared
npm run init # creates ~/.fractal and the MCP config to paste into Claude
npm run ingest ~/Obsidian # or point it at any folder of notes/markdown
npm run viz # look at the tree it built — this is where it gets obvious
npm run promote -- --apply # episodic → semantic, needs FRACTAL_LLM_URL (see below)Decisions and rotations — these need you, not a command
| | What | Why it can't be automated |
|---|---|---|
| 🔑 | Rotate the NVIDIA key | It was shared in plaintext during a session. Nothing in this repo contains it — grep-verified before every commit — but regenerate it |
| ⚖️ | Legal review of /privacy | Whoever runs the server is the data controller. The page describes what the software does, accurately; that is not the same as reviewed |
| 🌐 | Deploy — droplet, domain, Cloudflare, systemd, backups | DEPLOY.md §0. fractal has never run anywhere but in tests |
| ⚖️ | Settle MemHarness's licence before using their weights | Apache-2.0 on the GitHub badge, license:mit in the HuggingFace metadata. They are pluggable today (FRACTAL_LLM_URL at a vLLM serving them) but they are fine-tunes on ALFWorld and WebShop |
| 🔗 | Point landing/index.html's APP_URL at the real droplet domain | It ships as https://app.example.com on purpose — every "Sign up"/"Log in" button rewrites from that one constant. Deploying the landing page before this is set sends visitors nowhere real |
| 🌐 | Deploy landing/ to Vercel — Root Directory landing, no build command | landing/README.md. Static, stateless, independent of the droplet's domain — connect it under Vercel's own Settings → Domains once it's live |
Measured, with the caveat stated
Generalization holds — but only through a narrow gate, and the gate is fitted.
npm run eval:generalizzazione answers the question that was open: does storing the
abstract strategy make a precedent surface when the same problem recurs in a different
case?
- A (observation only): 0/6
- B (observation + strategy): 0/6 — the strategy alone changes nothing
- G (
generalizza: true): 6/6, zero false positives
The diagnosis is the interesting part. Retrieval was never the problem: the raw recall finds the right precedent at 0.900. The entity axis — the thing that makes the verdict decidable — then throws it away, because a new case with the same problem has different entities by construction. The axis that prevents false positives is the same one that prevents generalization.
generalizza: true lets a precedent through without shared entities only if it carries
an accepted abstract strategy, gated on a true cosine against the strategy text. Two
things about that gate deserve suspicion, and both are in the code comment:
- It needs a real cosine, computed on purpose, because the
scorefrom recall is not a similarity — with RRF fusion it is a normalised rank, measured at 0.900 for a relevant query and 0.900 for one about office plants. - The threshold (0.84) sits in a 0.04 margin — relevant queries hit 0.86–0.87 against strategies, unrelated ones reach 0.83. That is fitted to nine samples on one embedder. Zero false positives here does not generalize to zero false positives anywhere.
Calibrating it against a public dataset was tried, and it does not transfer.
npm run eval:soglia runs the threshold against 1,379 Italian sentence pairs with human
similarity judgements (STS Benchmark, PhilipMay/stsb_multi_mt) — a thousand real
judgements instead of nine sentences written by whoever picked the number. The result is
worth reading in full:
| threshold | lets through similar pairs | lets through different pairs | |---|---|---| | 0.84 (in use) | 100% | 72% | | 0.90 | 97% | 15% | | 0.92 | 91% | 5% |
By that yardstick 0.84 barely filters at all, and 0.92 is the right answer. But moving to 0.92 takes generalization from 6/6 to 0/6. The two tasks live in different regions of the same scale: STS pairs are paraphrases — same content, different words — and sit at 0.92–0.96; ours are rule ↔ new case — different content, same underlying problem — and sit at 0.86–0.87. A public dataset can calibrate the embedder, not the task.
So the threshold remains uncalibrated for the thing it actually gates, and now that is a
measured statement rather than a suspicion. It ships opt-in and off, with both numbers
next to it, and FRACTAL_DECISION_MIN_SIM_STRATEGIA to retune it. Making it the default would be the same mistake as the 0.25 threshold. It is
reachable as decision_recall(generalizza: true) on both doors — it was written, measured,
green in the benches and not wired to either tool for a while, which meant that from the
product's point of view the claim did not hold at all. A bench check now fails if the
parameter disappears from the tool schema again.
Measured limits, not to-do items. These are closed with numbers next to them, in ROADMAP.md: a bigger embedder does not pay for itself; rebuilding the tree makes the descent worse (5/10 → 3/10); the descent's ceiling with this structure is ~73% and beating it needs a different structure, not a better router; throughput is ~30 ops/s per process and scales with processes, not RAM.
Status
- [x] Native TypeScript engine (zero runtime dependencies)
- [x] O(1) add via staging buffer
- [x] O(log N) recall with multi-branch beam search
- [x] Balance guard (balanced tree even with near-identical vectors)
- [x] Persist / reload JSON (base64 Float32 vectors, atomic write)
- [x] OpenClaw plugin shell (verified contract)
- [x] Local semantic embedder via Transformers.js (multilingual)
- [x] Incremental insert (add without a full rebuild)
- [x] Near-duplicate dedup +
