Skip to content

v0.3.2

Choose a tag to compare

@github-actions github-actions released this 02 Sep 23:52
· 176 commits to master since this release

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 on subject + scope + the raw scopeRoot string, and scopeRoot is path.resolve(root) at capture time, which preserves whatever drive-letter/segment case the invoking shell happened to report. On Windows, cd /c/Projects/foo and cd /c/projects/foo are the same directory but resolve to differently-cased strings, so two mem remember calls for the same project from two differently-cased shells produced two distinct bucket keys instead of one. Reproduced against the built bundle: recording package-manager=pnpm from C:\Projects\token-goat-mem and package-manager=npm from C:\projects\token-goat-mem left both facts active in mem 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.

    bucketKey now case-folds its root component through the same win32-only normalizePath that retrieval.ts already applies when comparing paths for scope binding (moved to src/pathUtils.ts so both modules import one definition instead of drifting comment-for-comment; retrieval.ts already imports contradiction.ts, so re-exporting the function from retrieval.ts itself would have created a cycle). The fold stays win32-only, matching anchors.ts's FS_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.json broke every session for a collaborator without mem on PATH -- mem init claude-code --root . writes a SessionStart hook running mem 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 without mem installed hit bash: 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 broken mem never blocks a session) had no effect here, because the failure happened one level up, in the hook invocation itself, before mem ever 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: the command -v gate skips the call entirely when mem is missing, and the trailing || true forces exit 0 either way, since a bare && guard alone would still exit 1 (and could still be surfaced as a failed hook) when mem is 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 to settings.local.json would sidestep the sharing problem at the source, but that is a larger design decision left for a separate pass.

  • mem import --from-json silently accepted a scoped fact with no binding -- validateJsonFact checked scope but never cross-checked scopeRoot, so a hand-edited export, an export from a version predating scope_root, or a trimmed migration file could import a project/path-scoped fact with a missing or empty-string scopeRoot, reported as imported. Downstream the two consumers disagreed about an empty string: retrieval.ts's isBoundToRoot excluded it (so plain mem recall never returned it -- an invisible fact), while integration-seam.ts's isInScope only excluded null, so scopeRoot: "" fell through to resolvePath(""), resolving to process.cwd() -- putting the fact in scope for --hint-format from any project whenever --root equaled the cwd, which is the normal case. A project fact could leak into every project's hint block.

    validateJsonFact now rejects a non-global fact with no non-empty-string scopeRoot, naming the index and the problem. A global fact's stray scopeRoot is normalized to null rather than rejected, since global ignores it everywhere it is read. isInScope now excludes an empty/whitespace-only scopeRoot the same way isBoundToRoot already did, closing the leak for facts already in a store today. Absolute paths are still not required: README already documents verbatim cross-machine scopeRoot behavior for --from-json, and requiring isAbsolute would break that contract.

  • --scope path was documented but unreachable -- capture.ts's applyOptionalFields set scopeRoot = resolve(root) for every non-global scope, so mem remember "auth.ts owns migrations" --scope path --root . -- the only invocation the README suggested -- bound the fact to the project directory, not the file. A path fact behaved exactly like a project fact: mem recall --hint-format --root . --context-files src/other.ts returned it for every file in the project, not just the one it was supposedly bound to. remember, suggest, edit, and import --from-md now accept --path <file>, resolved against --root and stored as scopeRoot when --scope path is given. --scope path without --path, or --path without --scope path, now exits 1 with a message naming the missing flag instead of silently binding to the wrong thing.

  • mem list could not tell projects apart -- its summary line showed [kind/status] with no project/path binding, so in project B, mem list --scope project showed project A's decisions indistinguishably, and a user could run mem forget/mem pin against the wrong project's fact by id. The summary line (also used by mem review's bucketed listings) now appends the binding for non-global facts ([kind/status @scopeRoot]); global facts and --json output are unchanged.

  • file-absent affirmed 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 plain false, and file-absent mapped that false straight to affirmed. Reproduced against the built bundle with a pnpm-style junction, node_modules/foo -> node_modules/.pnpm/foo, both inside the anchor root, with node_modules/.pnpm/foo/package.json present on disk: mem remember "dependency foo was removed" --anchor "file-absent node_modules/foo" showed freshness=affirmed on mem show, asserting as verified ground truth that the dependency was gone while it demonstrably was not. file-absent node_modules/foo/package.json affirmed the same way. Any project with a symlinked node_modules, src, or packages directory got this fabrication for every file-absent anchored beneath it -- and "we removed dependency X" anchored file-absent node_modules/X is precisely the anchor an agent writes. file-exists had the opposite-but-equally-wrong instinct: it mapped the same refusal to contradicted, asserting the file definitely does not exist, when the honest answer is that mem cannot see through the symlink either way.

    evaluateTokens now checks containsSymlink for both file-exists and file-absent before calling existsFile, and returns unverified for either predicate on a detected symlink escape, rather than letting a false false flow into existsFile's own boolean. existsFile no 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, and contradicted/affirmed were each a lie for one half of the file-exists/file-absent pair. Two existing tests encoded the bug as intended (asserting contradicted/affirmed for the outside-root symlink case) and are flipped to unverified; a new inside-root case covers the exact pnpm junction shape above.

  • glob-exists contradicted patterns that named files which plainly existed -- three separate bugs stacked in the same function. First, evaluateGlobExists split 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 .git or node_modules even when the pattern named it directly -- glob-exists node_modules/pkg/index.js and glob-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 with src/a.ts and node_modules/pkg/index.js both present: glob node_modules/pkg/index.js, glob node_modules/**, and glob ./src/*.ts all recorded contradicted and were excluded from ground truth and flagged under mem review for the user to forget, while only the exact-syntax glob src/*.ts came back correct.

    Pattern splitting now also splits on \ when FS_CASE_INSENSITIVE (win32) is set, and drops empty . segments rather than choking on them. The .git/node_modules skip now applies only when the segment that matched the entry was itself a wildcard (*, ?, or the recursive segment): a literal segment naming .git or node_modules is treated as an explicit request to descend and is honored, while */pkg/target.txt still refuses to walk into node_modules reached only by the wildcard *. The existing budget (MAX_GLOB_ENTRIES_SCANNED, yielding unverified on 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" or file-exists /etc/passwd was accepted at mem remember/mem edit time and then read unverified forever -- 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. validateAnchorSyntax now runs it against every path-taking argument of every predicate (both operands of file-newer-than, every candidate of newest-of, the sole path argument of the rest) and raises InvalidAnchorError for 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 permanently unverified is now rejected at capture time with exit 1. Facts that already stored such an anchor before this change are unaffected -- this only gates new remember/edit/import --from-md calls (JSON import's own validation path is unchanged).

  • file-newer-than <a> <b> affirmed forever once b was deleted -- evaluateFileNewerThan (src/anchors.ts) returned affirmed whenever b's mtime was null, on the reasoning that a missing comparison target trivially loses to an existing one. In practice this means a fact anchored file-newer-than generated.ts schema.prisma stays certified as ground truth forever after schema.prisma is deleted or moved -- exactly the moment the fact ("generated.ts is current with schema.prisma") stops being true, since there is no longer anything for generated.ts to 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. evaluateFileNewerThan now returns unverified when b is missing, regardless of whether a exists.

  • git-tracked returned a false contradicted under 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, and evaluateGitTracked treated any path missing from the parsed entry table as contradicted. 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 a sharedindex.* file) and sdir (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 and core.splitIndex/git update-index --split-index: git ls-files listed both tracked files, while git-tracked returned contradicted for both, because neither survived in the main index's own entry table after the split.

    readGitIndexPathsUncached now 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 }, where complete is false if any extension signature encountered began with a lowercase letter. evaluateGitTracked now returns affirmed on a hit as before (a listed path is genuinely tracked even in a delta index), but a miss is contradicted only when complete is true -- otherwise unverified, 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 (returns null, surfacing as unverified), consistent with every other anomaly this parser already refuses to guess through.

  • mem uninstall left an empty file behind and took a .bak snapshot of nothing but its own content -- writeManagedFile always ran backupIfNeeded on 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.bak snapshot 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, so mem uninstall claude-code on a project where mem init claude-code was the only thing that had ever touched .claude/settings.json left a zero-byte-of-meaning {} sitting in the tree, reported as a normal update.

    backupIfNeeded moved out of writeManagedFile'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 new isEmptyManagedContent predicate -- deliberately stricter than the existing whitespace-tolerant isBlank, requiring an exact "" or an empty {}/[] -- decides when uninstall should unlink the file outright rather than write an empty husk; a new "delete" WiringFileAction reports 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. uninstallTasksJson also now prunes the version: "2.0.0" key mem init itself scaffolds in a from-scratch tasks.json once 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 .bak behavior is corrected to match, and mem init --help now says to gitignore *.token-goat-mem.bak.

  • A multi-file mem init/mem uninstall could write some files, hit a conflict on a later one, and say nothing about the files it already changed -- runInstall and runUninstall computed and wrote each managed file in sequence, so a WiringConflictError thrown while processing (for example) keybindings.json left tasks.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.

    runInstall and runUninstall now 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 shape runDescribe already 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 --help documents the guarantee. mem uninstall --all continues past a single tool's failure instead of aborting the whole batch, reporting <tool>: failed inline for the one that failed while still uninstalling and reporting the rest, and exits 1 if any tool failed -- so a broken tasks.json no longer hides the fact that codex and copilot-cli uninstalled cleanly.

  • mem recall gave no signal when its anchor time budget ran out -- retrieve() gives every fact's anchor a shared anchorTimeBudgetMs deadline (default 100ms) and forces any anchor still unevaluated when that deadline passes to unverified, 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 in glob-exists/git-tracked could silently degrade every remaining fact's freshness to unverified with nothing in the output to distinguish "mem checked and truly cannot tell" from "mem ran out of time and did not check." An affirmed fact silently downgrading to a hint, or a contradicted one silently stopping being withheld, looked identical to normal operation.

    evaluateAnchor now accepts an optional out-parameter set when its returned unverified is 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 as anchorBudgetHits on its outcome; mem recall prints note: anchor budget exhausted; N freshness verdict(s) reported as unverified when 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 scopeRoot and silently could never be recalled now reports skipped_error at 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-md invocation with --scope path and no --path (or --path with no --scope path) now exits 1 where it previously succeeded and silently bound to the wrong root.
  • mem remember/edit/import --from-md now 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 evaluate unverified. Already-stored anchors of this shape are unaffected; JSON import's validation path is unchanged.