Repository navigation
v0.3.2
Fixed
-
Contradiction detection split one Windows project into two buckets, on the same machine, in the same session --
bucketKey(src/contradiction.ts) keyed a fact's contradiction bucket onsubject+scope+ the rawscopeRootstring, andscopeRootispath.resolve(root)at capture time, which preserves whatever drive-letter/segment case the invoking shell happened to report. On Windows,cd /c/Projects/fooandcd /c/projects/fooare the same directory but resolve to differently-cased strings, so twomem remembercalls for the same project from two differently-cased shells produced two distinct bucket keys instead of one. Reproduced against the built bundle: recordingpackage-manager=pnpmfromC:\Projects\token-goat-memandpackage-manager=npmfromC:\projects\token-goat-memleft both factsactiveinmem list-- two directly contradictory facts about the same subject in the same project, neither superseded, both surfacing as ground truth simultaneously. That is the exact failure the contradiction layer exists to prevent, and every existing contradiction test used a consistently-cased root, so nothing exercised the mismatch.bucketKeynow case-folds its root component through the same win32-onlynormalizePaththatretrieval.tsalready applies when comparing paths for scope binding (moved tosrc/pathUtils.tsso both modules import one definition instead of drifting comment-for-comment;retrieval.tsalready importscontradiction.ts, so re-exporting the function fromretrieval.tsitself would have created a cycle). The fold stays win32-only, matchinganchors.ts'sFS_CASE_INSENSITIVE: macOS is case-insensitive by default but supports case-sensitive APFS volumes, so folding unconditionally would trade a missed match for a false one there. -
A committed
.claude/settings.jsonbroke every session for a collaborator without mem on PATH --mem init claude-code --root .writes aSessionStarthook runningmem recall --hint-format --root "$CLAUDE_PROJECT_DIR"into.claude/settings.json, and that file is not gitignored, so it is ordinarily committed and shared. A collaborator who clones the repo withoutmeminstalled hitbash: line 1: mem: command not found(exit 127) at the start of every Claude Code session -- the fail-open contract this seam otherwise guarantees (README: a missing or brokenmemnever blocks a session) had no effect here, because the failure happened one level up, in the hook invocation itself, beforememever got a chance to fail open.The installed command is now
command -v mem >/dev/null 2>&1 && mem recall --hint-format --root "$CLAUDE_PROJECT_DIR" || true: thecommand -vgate skips the call entirely whenmemis missing, and the trailing|| trueforces exit 0 either way, since a bare&&guard alone would still exit 1 (and could still be surfaced as a failed hook) whenmemis absent.docs/integrations/claude-code.md's hook example and the settings.json this installer writes are covered by a structural test that compares them directly, so the two cannot drift. Deliberately out of scope: switching the install target tosettings.local.jsonwould sidestep the sharing problem at the source, but that is a larger design decision left for a separate pass. -
mem import --from-jsonsilently accepted a scoped fact with no binding --validateJsonFactcheckedscopebut never cross-checkedscopeRoot, so a hand-edited export, an export from a version predatingscope_root, or a trimmed migration file could import aproject/path-scoped fact with a missing or empty-stringscopeRoot, reported asimported. Downstream the two consumers disagreed about an empty string:retrieval.ts'sisBoundToRootexcluded it (so plainmem recallnever returned it -- an invisible fact), whileintegration-seam.ts'sisInScopeonly excludednull, soscopeRoot: ""fell through toresolvePath(""), resolving toprocess.cwd()-- putting the fact in scope for--hint-formatfrom any project whenever--rootequaled the cwd, which is the normal case. A project fact could leak into every project's hint block.validateJsonFactnow rejects a non-global fact with no non-empty-stringscopeRoot, naming the index and the problem. Aglobalfact's strayscopeRootis normalized tonullrather than rejected, since global ignores it everywhere it is read.isInScopenow excludes an empty/whitespace-onlyscopeRootthe same wayisBoundToRootalready did, closing the leak for facts already in a store today. Absolute paths are still not required: README already documents verbatim cross-machinescopeRootbehavior for--from-json, and requiringisAbsolutewould break that contract. -
--scope pathwas documented but unreachable --capture.ts'sapplyOptionalFieldssetscopeRoot = resolve(root)for every non-global scope, somem remember "auth.ts owns migrations" --scope path --root .-- the only invocation the README suggested -- bound the fact to the project directory, not the file. Apathfact behaved exactly like aprojectfact:mem recall --hint-format --root . --context-files src/other.tsreturned it for every file in the project, not just the one it was supposedly bound to.remember,suggest,edit, andimport --from-mdnow accept--path <file>, resolved against--rootand stored asscopeRootwhen--scope pathis given.--scope pathwithout--path, or--pathwithout--scope path, now exits 1 with a message naming the missing flag instead of silently binding to the wrong thing. -
mem listcould not tell projects apart -- its summary line showed[kind/status]with no project/path binding, so in project B,mem list --scope projectshowed project A's decisions indistinguishably, and a user could runmem forget/mem pinagainst the wrong project's fact by id. The summary line (also used bymem review's bucketed listings) now appends the binding for non-global facts ([kind/status @scopeRoot]); global facts and--jsonoutput are unchanged. -
file-absentaffirmed through a symlink, certifying a present file as removed --existsFile(src/anchors.ts) resolved a symlink refusal (containsSymlink, at the target or at any intermediate directory) to a plainfalse, andfile-absentmapped thatfalsestraight toaffirmed. Reproduced against the built bundle with a pnpm-style junction,node_modules/foo -> node_modules/.pnpm/foo, both inside the anchor root, withnode_modules/.pnpm/foo/package.jsonpresent on disk:mem remember "dependency foo was removed" --anchor "file-absent node_modules/foo"showedfreshness=affirmedonmem show, asserting as verified ground truth that the dependency was gone while it demonstrably was not.file-absent node_modules/foo/package.jsonaffirmed the same way. Any project with a symlinkednode_modules,src, orpackagesdirectory got this fabrication for everyfile-absentanchored beneath it -- and "we removed dependency X" anchoredfile-absent node_modules/Xis precisely the anchor an agent writes.file-existshad the opposite-but-equally-wrong instinct: it mapped the same refusal tocontradicted, asserting the file definitely does not exist, when the honest answer is that mem cannot see through the symlink either way.evaluateTokensnow checkscontainsSymlinkfor bothfile-existsandfile-absentbefore callingexistsFile, and returnsunverifiedfor either predicate on a detected symlink escape, rather than letting a falsefalseflow intoexistsFile's own boolean.existsFileno longer does its own symlink check at all -- by the time it runs, the caller has already ruled that case out. This is a straightforward P3 read: a symlink means mem cannot safely resolve the path, so it can assert neither presence nor absence, andcontradicted/affirmedwere each a lie for one half of thefile-exists/file-absentpair. Two existing tests encoded the bug as intended (assertingcontradicted/affirmedfor the outside-root symlink case) and are flipped tounverified; a new inside-root case covers the exact pnpm junction shape above. -
glob-existscontradicted patterns that named files which plainly existed -- three separate bugs stacked in the same function. First,evaluateGlobExistssplit a pattern only on/, so a leading./(./src/*.ts) left a stray.segment that never matches a real directory entry, and on Windows a pattern typed with backslashes (src\*.ts) was one opaque segment instead of two. Second, and worse, the walk refused to descend into a directory literally named.gitornode_moduleseven when the pattern named it directly --glob-exists node_modules/pkg/index.jsandglob-exists node_modules/**both contradicted a file that existed, because the hardcoded skip did not distinguish "the wildcard happened to match this name" from "the pattern explicitly asked for this directory". Reproduced against the built bundle withsrc/a.tsandnode_modules/pkg/index.jsboth present:glob node_modules/pkg/index.js,glob node_modules/**, andglob ./src/*.tsall recordedcontradictedand were excluded from ground truth and flagged undermem reviewfor the user to forget, while only the exact-syntaxglob src/*.tscame back correct.Pattern splitting now also splits on
\whenFS_CASE_INSENSITIVE(win32) is set, and drops empty.segments rather than choking on them. The.git/node_modulesskip now applies only when the segment that matched the entry was itself a wildcard (*,?, or the recursive segment): a literal segment naming.gitornode_modulesis treated as an explicit request to descend and is honored, while*/pkg/target.txtstill refuses to walk intonode_modulesreached only by the wildcard*. The existing budget (MAX_GLOB_ENTRIES_SCANNED, yieldingunverifiedon overrun) still bounds the cost of descending into a large tree the pattern explicitly names.Third, capture-time syntax validation (
validateAnchorSyntax,src/capture.ts) checked only argument count and shell-metacharacter safety, so--anchor "glob-exists ../x"orfile-exists /etc/passwdwas accepted atmem remember/mem edittime and then readunverifiedforever -- a fact whose anchor could never possibly affirm, rotting silently instead of failing loudly.anchorPathWithinRoot(src/anchors.ts) was already exported for exactly this containment check but nothing called it.validateAnchorSyntaxnow runs it against every path-taking argument of every predicate (both operands offile-newer-than, every candidate ofnewest-of, the sole path argument of the rest) and raisesInvalidAnchorErrorfor a..-traversing or absolute argument, naming the offending argument. This is a released CLI behavior change: an anchor string that used to be silently accepted and permanentlyunverifiedis now rejected at capture time with exit 1. Facts that already stored such an anchor before this change are unaffected -- this only gates newremember/edit/import --from-mdcalls (JSON import's own validation path is unchanged). -
file-newer-than <a> <b>affirmed forever oncebwas deleted --evaluateFileNewerThan(src/anchors.ts) returnedaffirmedwheneverb's mtime wasnull, on the reasoning that a missing comparison target trivially loses to an existing one. In practice this means a fact anchoredfile-newer-than generated.ts schema.prismastays certified as ground truth forever afterschema.prismais deleted or moved -- exactly the moment the fact ("generated.ts is current with schema.prisma") stops being true, since there is no longer anything forgenerated.tsto be newer than. You cannot compare two files when one of them does not exist; affirming a one-sided comparison is exactly the fabrication P3 forbids.evaluateFileNewerThannow returnsunverifiedwhenbis missing, regardless of whetheraexists. -
git-trackedreturned a falsecontradictedunder split index and sparse index --readGitIndexPathsUncached(src/anchors.ts) parsed.git/index's header and entry table and stopped, never inspecting the trailing extension section, andevaluateGitTrackedtreated any path missing from the parsed entry table ascontradicted. Git's index-extension format reserves a lowercase first signature letter to mean mandatory: a reader that does not understand the extension is supposed to refuse the index rather than proceed as if it were not there.link(split index -- the main index's entry table holds only paths that differ from asharedindex.*file) andsdir(sparse index -- an entire directory outside the sparse-checkout cone collapses into one entry) are both lowercase, and this parser silently ignored that signal. Reproduced against the built bundle with a real repository under git 2.53 andcore.splitIndex/git update-index --split-index:git ls-fileslisted both tracked files, whilegit-trackedreturnedcontradictedfor both, because neither survived in the main index's own entry table after the split.readGitIndexPathsUncachednow walks the extension section after the entry loop, trying a 20-byte (SHA-1) then a 32-byte (SHA-256) trailer and accepting whichever walk lands exactly on the trailer boundary; it returns{ paths, complete }, wherecompleteisfalseif any extension signature encountered began with a lowercase letter.evaluateGitTrackednow returnsaffirmedon a hit as before (a listed path is genuinely tracked even in a delta index), but a miss iscontradictedonly whencompleteistrue-- otherwiseunverified, since "not listed in this partial entry table" is not evidence of "not tracked". An extension whose declared size cannot be reconciled with either trailer length aborts the whole parse (returnsnull, surfacing asunverified), consistent with every other anomaly this parser already refuses to guess through. -
mem uninstallleft an empty file behind and took a.baksnapshot of nothing but its own content --writeManagedFilealways ranbackupIfNeededon the first write to any existing file, uninstall included, so removing the last tool from a file mem itself had created (nothing pre-existing to protect) still produced a<file>.token-goat-mem.baksnapshot of mem's own scaffold. Worse, when the computed next content collapsed to nothing -- an empty string, or{}/[]for a JSON-managed file -- uninstall wrote that empty husk back to disk instead of removing the file, somem uninstall claude-codeon a project wheremem init claude-codewas the only thing that had ever touched.claude/settings.jsonleft a zero-byte-of-meaning{}sitting in the tree, reported as a normalupdate.backupIfNeededmoved out ofwriteManagedFile's always-run path and is now opt-in per call ({ backup: true }on the install path only; uninstall passes{ backup: false, deleteIfEmpty: true }). A newisEmptyManagedContentpredicate -- deliberately stricter than the existing whitespace-tolerantisBlank, requiring an exact""or an empty{}/[]-- decides when uninstall should unlink the file outright rather than write an empty husk; a new"delete"WiringFileActionreports that outcome distinctly from"update"/"remove"/"noop". The stricter check matters: a pre-existing file containing only whitespace must survive uninstall byte-for-byte rather than being swept up as "empty" and deleted, and a regression test pins exactly that.uninstallTasksJsonalso now prunes theversion: "2.0.0"keymem inititself scaffolds in a from-scratchtasks.jsononce it is the sole remaining key, so that file can actually reach the empty state the delete path checks for instead of getting stuck one key short forever. CLAUDE.md's description of the.bakbehavior is corrected to match, andmem init --helpnow says to gitignore*.token-goat-mem.bak. -
A multi-file
mem init/mem uninstallcould write some files, hit a conflict on a later one, and say nothing about the files it already changed --runInstallandrunUninstallcomputed and wrote each managed file in sequence, so aWiringConflictErrorthrown while processing (for example)keybindings.jsonlefttasks.json-- processed just before it in the same call -- already written to disk, with the command exiting non-zero and no indication anything had changed. A user seeing only the error had no way to know part of the operation had already happened without diffing the tree themselves.runInstallandrunUninstallnow compute every managed file's next content up front, against a consistent read of current disk state, before performing any actual write -- mirroring the dry-run shaperunDescribealready used for exactly this reason. A conflict anywhere in that computation now aborts before a single byte is written, for both install and uninstall.mem init --helpdocuments the guarantee.mem uninstall --allcontinues past a single tool's failure instead of aborting the whole batch, reporting<tool>: failedinline for the one that failed while still uninstalling and reporting the rest, and exits 1 if any tool failed -- so a brokentasks.jsonno longer hides the fact thatcodexandcopilot-cliuninstalled cleanly. -
mem recallgave no signal when its anchor time budget ran out --retrieve()gives every fact's anchor a sharedanchorTimeBudgetMsdeadline (default 100ms) and forces any anchor still unevaluated when that deadline passes tounverified, the same output a fact with a no-op anchor or a genuinely inconclusive predicate produces. A large store, a slow filesystem, or a query anchored heavily inglob-exists/git-trackedcould silently degrade every remaining fact's freshness tounverifiedwith nothing in the output to distinguish "mem checked and truly cannot tell" from "mem ran out of time and did not check." Anaffirmedfact silently downgrading to a hint, or acontradictedone silently stopping being withheld, looked identical to normal operation.evaluateAnchornow accepts an optional out-parameter set when its returnedunverifiedis a time-budget bailout rather than a genuine predicate outcome (a budget-limited verdict is never memoized, so a later cache hit can never masquerade as a budget hit).retrieve()counts these per call and returns the total asanchorBudgetHitson its outcome;mem recallprintsnote: anchor budget exhausted; N freshness verdict(s) reported as unverifiedwhen that count is nonzero, and stays silent otherwise -- the ordinary small-store case that finishes well inside the deadline is unaffected.
Changed
- A row that previously imported with an unbound
scopeRootand silently could never be recalled now reportsskipped_errorat import time instead. It was unrecallable either way; this surfaces that at the point of import rather than after the fact. - A
mem remember/suggest/edit/import --from-mdinvocation with--scope pathand no--path(or--pathwith no--scope path) now exits 1 where it previously succeeded and silently bound to the wrong root. mem remember/edit/import --from-mdnow reject a path-taking anchor argument that traverses above the root (..) or names an absolute path (e.g.file-exists /etc/passwd) at capture time, where it previously succeeded and stored an anchor that could only ever evaluateunverified. Already-stored anchors of this shape are unaffected; JSON import's validation path is unchanged.