Skip to main content
Kodelyth ECC
architecture

A Local Memory That Survives a git clone, a Move, and a Rename

Keying a project by its absolute path means every memory orphans the moment you move the repo. ECC keys by the normalised git remote instead, reads both ids for backward compatibility, redacts secrets on capture, and fuses recency and outcome into recall.

By Kodelyth · · 7 min read

Three git remote URL forms — scp, https with a trailing slash, and ssh with embedded credentials — all normalise to remote:github.com/owner/repo and hash to one stable project id, so the memories survive a move.

ECC keeps a local memory store — solutions, gotchas, and patterns captured from past sessions, retrieved with BM25, and injected back at the start of a related session. It lives on your disk as an append-only JSONL log. Nothing is ever transmitted anywhere.

A store like that lives or dies on one question it has to answer silently: which memories belong to this project? Get that wrong and recall returns nothing, which — for a recall-driven store — is indistinguishable from having learned nothing at all.

The bug: identity by path

Memory used to key a project by sha256(absolute path). The observer that captured sessions keyed it by git remote. The two disagreed the moment anything moved: clone the repo to a new machine, move it to a different folder, rename the directory — the absolute path changes, its hash changes, and every memory filed under the old hash is orphaned. Still on disk. Never found again.

Identity by normalised git remote

The fix is to key on something stable across all of those moves: the git remote. But remotes come in many shapes for the same repo, so the resolver normalises first.

git@github.com:owner/repo.git     ─┐
https://github.com/owner/repo/     ├─→  remote:github.com/owner/repo  → one id
ssh://git@github.com/owner/repo    ─┘

That is not a diagram of intent — it is what the code actually does. Run the four forms (including one carrying embedded credentials) through the resolver:

github.com/sifxprime/kodelyth-ecc  => sha12: 142359ac19f9
github.com/sifxprime/kodelyth-ecc  => sha12: 142359ac19f9
github.com/sifxprime/kodelyth-ecc  => sha12: 142359ac19f9
github.com/sifxprime/kodelyth-ecc  => sha12: 142359ac19f9

The normaliser strips the scheme, the scp-style git@ user, a trailing .git, trailing slashes, and — importantly — any ://user:pass@ credentials, so a credential-bearing remote and a clean one map to the same project and no secret ever lands in a hash input that might be logged. A repo with no remote (local only, or not a git directory at all) falls back to the path id, which is fine: a repo that never moves cannot be orphaned by moving.

Read-both, so nothing already captured is lost

Changing the identity scheme is the kind of fix that quietly destroys the data it was meant to protect, unless you plan for the transition. New memories are written under the git-remote id. But recall doesn't only look there — it matches every id that could belong to this project:

function matchingProjectIds(projectRoot, opts = {}) {
  const ids = new Set();
  ids.add(resolveProjectId(projectRoot, opts)); // git-remote-first
  ids.add(legacyPathId(projectRoot));           // the old sha256(path)
  return [...ids];
}

Memories captured under the old path scheme are still found via the legacy id; new ones accumulate under the stable one. When the repo has no remote the two ids are identical and this collapses to a single lookup. No migration, no loss.

Secrets are redacted before they are stored

A captured memory is written to disk, surfaced by recall, and injected into future sessions. So a credential that slips into a captured problem or approach doesn't just sit in a log — it re-surfaces, in context, in every related session afterward. Capture redacts before writing:

problem:  redact(String(problem)).slice(0, 500),
approach: redact(String(approach)).slice(0, 2000),

redact matches high-confidence provider token formats anywhere in the text (AWS, GitHub, OpenAI/Anthropic, Stripe, Slack, Google, npm, JWTs, PEM blocks) and assignment forms (password=, Authorization:, api_key=). Real output:

fixed the 500: set api_key=<REDACTED:anthropic-key> and retry
pushed with token <REDACTED:github-token> by mistake
db works: password=<REDACTED> in the url

Every pattern is linear — no nested quantifiers, no adjacent \s*\s* — so none can backtrack catastrophically on hostile input. That is the same ReDoS lesson the prompt-injection guard paid for, applied preemptively here.

Recall fuses relevance, recency, and outcome

BM25 decides relevance and a relative floor gates it (that threshold has its own story). Fusion only reorders the survivors, so that among similarly-relevant memories a recent, proven one outranks an old or failed one:

function fuseScore(m) {
  const base = m.score || 0;                         // BM25 relevance
  let recency = 1, outcome = 1;
  const ageDays = (Date.now() - Date.parse(m.captured_at)) / 86400000;
  recency = 1 + 0.25 * Math.exp(-ageDays / 90);      // +25% fresh, decays ~90d
  if (m.resolved === true)  outcome = 1.3;           // confirmed to work
  else if (m.resolved === false) outcome = 0.6;      // didn't stick
  return base * recency * outcome;                   // relevance × recency × outcome
}

The ordering of effects is deliberate. Relevance gates; recency and outcome only re-rank what was already relevant. An ancient, confirmed fix still beats a fresh guess, because base dominates when the relevance gap is real — the multipliers are tie-breakers, not overrides. It is a pure function of one memory, so it is unit-tested directly.

The semantic tier is opt-in and on-device

The default recall path is pure BM25: synchronous, zero-dependency, zero-network, exactly as advertised. A semantic layer exists, but it is off unless you ask for it:

export KODELYTH_MEMORY_EMBEDDINGS=on

With the flag on and the optional @xenova/transformers dependency present, recall embeds the query with a local model — inference happens on your device, nothing leaves the machine — and fuses the cosine similarity with the normalised BM25 score:

final = weight * semantic + (1 - weight) * bm25norm   // weight defaults to 0.5

A result with no stored embedding keeps its BM25-normalised score, so a missing vector never unfairly sinks a relevant memory. And the whole thing degrades cleanly: a disabled flag, a missing optional dependency, or a model-load failure all collapse to the same safe BM25-only behaviour. You never get a worse result for turning it on and having it fail to load — you get the default.

Using it

Recall and capture work from any MCP-capable IDE (the recall_memory and capture_memory tools), or directly from the CLI:

kodelythecc memory capture --problem "CORS preflight failing on PUT" \
  --approach "added OPTIONS handler + Access-Control-Allow-Methods" --tags cors,express
✓ stored ad4e237fcedf0b58 — "CORS preflight failing on PUT"
kodelythecc memory recall "how did I fix CORS preflight" --limit 3
[0.70] CORS preflight failing on PUT
        → added OPTIONS handler + Access-Control-Allow-Methods  (cors, express)

Recall scopes to the current project's memories first — matching both ids — falling back to global ones when project-specific hits are sparse. Everything stays in ~/.kodelythecc/memory/: inspect it, edit it, or delete it whenever you like.


The identity resolver, redaction, and fusion shipped across the ECC 2.2x line and are dependency-free Node in scripts/memory. The whole store is local by design — the honest answer to "how do you know that about me?" is that it's a file on your disk, and nothing was sent anywhere.

npm i -g kodelyth-ecc && kodelythecc