I kept losing the thread across AI chat sessions. Ask about something, the idea evolves over three or four separate chats, and by the end I've got five overlapping conversations open and no clean record of which decision actually won, or why I changed my mind halfway through.
So I built Skein — paste a transcript in, it extracts the atomic claims (individual decisions and facts, not a summary), clusters them by topic, and when a new claim contradicts an old one, it chains onto it instead of overwriting it. Then you can actually query the result with real RAG.
Two things about the build turned out more interesting than I expected going in.
Claims chain, they don't overwrite
The core data model is almost embarrassingly simple. A claim looks like this:
{
id, text, topic, label,
status: "active" | "superseded" | "correction" | "discarded",
supersedes: string | null, // id of the claim this one replaces
}
When a new claim comes in on a topic that already has an active claim, and the text actually differs, the old claim flips to superseded and the new one gets tagged correction with supersedes pointing back:
export function applyNewClaims(existingClaims, newClaims) {
const claims = [...existingClaims];
for (const incoming of newClaims) {
const activeSameTopic = claims.find(
(c) => c.topic === incoming.topic && c.status === "active"
);
if (activeSameTopic && activeSameTopic.text !== incoming.text) {
activeSameTopic.status = "superseded";
claims.push({ ...incoming, status: "correction", supersedes: activeSameTopic.id });
} else if (!activeSameTopic) {
claims.push(incoming);
}
}
return claims;
}
Nothing gets deleted. Walking supersedes backward from any claim gives you the full decision history — Postgres, then Mongo, then back to Postgres because Mongo made relational queries harder than expected — instead of just the current answer with the reasoning stripped out.
It's naive on purpose: same topic, different text, is the whole conflict-detection heuristic. It'll misfire on any topic that legitimately holds multiple simultaneous active claims. I know exactly where the edges are and haven't hit them yet in real use, which felt like the right bar for shipping it rather than building a more sophisticated version speculatively.
Retrieval had to be decoupled from chat provider — Anthropic has no embeddings API
This one actually shaped the architecture. The app supports Anthropic, OpenAI-compatible (including local models via Ollama), and WebLLM running fully in-browser via WebGPU. Naturally I wanted embeddings to just come from whichever provider you'd picked for chat.
Anthropic doesn't offer an embeddings endpoint at all. Not a gap in my code — a gap in their API surface; they point people to Voyage AI. So if embedding were tied to chat provider, anyone using Anthropic (probably most people) would get zero real retrieval.
The fix was to stop treating "embedding provider" and "chat provider" as the same decision. Embeddings always run locally via WebLLM (a real embedding model, snowflake-arctic-embed-s, not a chat model pressed into service) regardless of what's answering the question. Free, no key, and it means retrieval quality doesn't depend on which chat provider happens to have an embeddings endpoint.
The retrieval layer has one more rule worth mentioning: semantic similarity has no concept of a correction. If the highest-scoring match for a query is a superseded claim — its old wording just happened to match more closely than whatever replaced it — the system resolves it to that topic's current head before it's used as context. Otherwise you'd get an embedding-powered way to confidently answer with outdated information, which would defeat the entire premise of the correction-chain model above it.
// One claim per topic in context, resolved to that topic's CURRENT head
const clusterByTopic = new Map(buildClusters(claims).map((cl) => [cl.topic, cl]));
const seenTopics = new Set();
const sources = [];
for (const { claim } of scored) {
if (sources.length >= TOP_K) break;
if (seenTopics.has(claim.topic)) continue;
seenTopics.add(claim.topic);
sources.push(clusterByTopic.get(claim.topic)?.head ?? claim);
}
Synthesis goes through whichever chat provider is selected, asked to answer strictly from the retrieved claims and cite them inline. If the claims don't contain enough to answer, it says so instead of guessing.
A CLI that shares prompts with the web app, not a copy of them
There's also a zero-dependency CLI doing the same extract → categorize → query pipeline with no browser involved — for anyone who wants to be fast and doesn't need to see a graph. It started as a hand-copied port of the web app's prompts. That lasted about a week before they drifted: a fix applied to the web app's extraction prompt never made it to the CLI's copy.
The actual fix wasn't "import everything from one side into the other" — the two have genuinely different provider layers (the web app supports three providers including a browser-only local model and passes around a settings object; the CLI is BYOK-only across two with flat params), and unifying that would mean papering over a real difference. Instead, just the prompt content moved into a shared module both sides import:
Skein/
shared/prompts.js <- the actual prompt strings, single source of truth
src/lib/ <- web app, imports from ../../shared/prompts
cli/src/lib/ <- CLI, imports from ../../../shared/prompts
One nice side effect: fixing the drift required rewording a sentence that referenced "the graph node" a label appears on — phrasing that didn't make sense from a CLI with no graph. Turned out the underlying advice (a label needs to read standalone, without the full claim text next to it) was equally true of the CLI's list output. It wasn't environment-specific after all, just narrowly worded. Once reworded generically, the whole prompt became shareable verbatim.
What it looks like
The graph clusters claims by topic using a real force-directed layout (not a canned graph library — dropped the bundle by about 400KB), with topic groups rendered as soft metaball blobs rather than one circle sized to the cluster's farthest node. Correction history shows on the node itself (a status ring, not a line back to a predecessor) — click any node, or query the graph, and the full chain shows in a compact popover.
Fully client-side. IndexedDB for storage, no backend, MIT licensed. Repo's at github.com/Virerra/Skein, live demo linked from the README. If you poke at it and something breaks, I'd genuinely like to know.


Top comments (5)
The "resolve superseded hits to the current head before injecting into context" step is the detail most RAG implementations skip entirely. Embeddings find what sounds like the answer; without that chain-walk you'd confidently retrieve stale decisions with high cosine similarity and never know.
One edge I'd be curious about: when two claims on the same topic differ in scope rather than contradict — "use Postgres for the user table" and "use Postgres for the audit log" are both active simultaneously, not a correction chain. The naive same-topic heuristic would supersede the first. Have you hit that yet, or does topic granularity from the extraction prompt stay fine enough to avoid it in practice?
Also, decoupling embedding provider from chat provider is the right call architecturally. The OpenAI-compatible ecosystem has the same gap in reverse — plenty of gateways expose
/v1/chat/completionsbut not/v1/embeddings, so anyone routing through a relay hits the same split you solved with local WebLLM.Good question, and the honest answer is no, I haven't hit that yet, but I don't think that's evidence the design handles it, I think it's evidence my test transcripts haven't produced two genuinely coexisting same-topic claims yet. Same-topic-different-text has been the whole heuristic since the start, documented as naive for exactly this reason, I just didn't have a concrete failure case to point at until now. Yours is a good one.
The interesting part is that I don't think tightening topic granularity actually fixes it. The extraction prompt asks for 1-2 word topics on purpose, that's what makes the graph's clustering useful. Scope topics tightly enough to separate "Postgres for the user table" from "Postgres for the audit log" and you've also fragmented every topic into near-single-claim clusters, which breaks the clustering for the 95% of cases where same-topic really does mean "these might conflict." So it's a real tradeoff, not a tuning knob: coarse topics for useful clustering vs. fine distinction for correct conflict detection, and I don't think you get both from topic granularity alone.
The fix is probably that "same topic" should never have been the actual test, it was always a cheap proxy for "might conflict." The real test is closer to what retrieval already does elsewhere in the system, actually checking whether the new claim contradicts the old one, not just whether they share a label. Same embeddings that already power retrieval could plausibly do that classification too. Appreciate you pushing on this, it's the first concrete failure case anyone's handed me for that heuristic instead of a hypothetical one.
On the gateway point, that's a good catch and it maps exactly to why embedding got decoupled from chat provider here. For what it's worth, the OpenAI-compatible path already fails loud if you point it at a /v1/embeddings that doesn't exist, real HTTP status and body in the error, not a silent hang, so at least the failure mode is diagnosable if you hit it through a relay.
The embedding-based contradiction check is the right direction — same infrastructure that already powers retrieval, just pointed at a different question ("does this contradict?" vs "what's related?"). The nice property is that it degrades gracefully: when the classifier is uncertain, you can still store both claims and flag the conflict for human resolution, rather than silently picking a winner.
The coarse-vs-fine topic tradeoff you articulated is the core tension, and I don't think it's specific to knowledge graphs. Every system that groups things for useful clustering pays this tax — model names in an API gateway, error categories in an observability pipeline, tags in a bug tracker. The label is always a lossy compression of the thing, and the compression ratio is the false-negative rate for conflict detection.
One thing that stood out: "the OpenAI-compatible path already fails loud." That property is underappreciated and it's the difference between a system you can operate and one you can only pray over. Silent degradation — model swapped, embedding silently mismatched, topic silently overwritten — is the failure mode that compounds, because by the time you detect it you've already made decisions on bad data. Loud failures are a feature.
Also, thanks for the concrete failure case. "Here's a real input that breaks the heuristic" is worth more than a dozen design reviews.
Good catch on the forced binary, that's a real gap, not just a nice-to-have. Fixed: the check now returns a three-way verdict instead of a boolean, and uncertain degrades to the same non-destructive outcome as compatible, don't auto-correct when the model isn't confident either way.
The human-resolution flow is where I'd draw the line for now, though, and I want to be upfront about why rather than just going quiet on it. It's a real feature (new status, a UI surface to actually review flagged conflicts), not a bugfix, and it's sitting on a design principle rather than a demonstrated case the way the scope-vs-contradiction bug was. I've been trying to hold everything on this project to "build it when something real needs it, not because it's a good idea in the abstract," and I'd rather keep that discipline here too. If you (or anyone else) hit an actual case where the uncertain-verdict path produces something you wish you could review and resolve by hand, that's exactly the evidence that would move it up the list.
Appreciate you pushing on this one too.
This is the right kind of discipline. Three-way verdict + "uncertain == non-destructive" is exactly the safe default — auto-correcting on low confidence is how knowledge graphs quietly corrupt themselves.
And holding the human-resolution UI until there's a real case is a good call. Most "review queues" die empty because the uncertain path almost never needs a human when the degrade-to-compatible rule is solid. If I hit a case where I wish I could override an uncertain verdict by hand, I'll file it with the actual transcript rather than a hypothetical.
Appreciate the transparent tradeoff writeup — rare and useful.