Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.9.0] - 2026-09-20

**No schema change. You do not need to re-run `setup.sql`.**

This release is about Pro stores, meaning your own Supabase project. It covers two things: gitmem now
works fully on a project that has only `setup.sql` in it, which is every customer project. And it
no longer downloads your whole memory every time a process starts.

### Fixed

- **gitmem no longer depends on an edge function your project doesn't have.** Some reads went
through `/functions/v1/ww-mcp`, an edge function that exists only on nTEG's own Supabase project
and was never shipped with the package. On your project every one of those reads returned 404,
and gitmem quietly carried on without the data. Every read and write now goes directly to your
project's REST API (PostgREST). What you'll notice:
- **The `session_start` thread panel now matches `list_threads`.** It used to fall back to the
local `threads.json` file, which can be stale, while `list_threads` read your Supabase table,
so the two could show different threads. Both now show the same threads from Supabase. A failed
session-history read also no longer throws away a successful thread read.
- **Your last session and recent decisions load at `session_start` again.** Before, that step
failed silently with `Failed to load last session`.
- Filter values containing a `.`, such as a title or a version string, are now always treated as
data. (GIT-97)

### Added

- **An on-disk copy of your embedding index, so short-lived processes stop re-downloading it.** In
local search mode, every gitmem process downloaded every learning with its 1536-number embedding
at startup and threw it away on exit. Sub-agents, parallel workers and CI jobs each paid that cost
again, and one user used up a Supabase egress allowance this way.
- **How it works now:** each start asks your store one small question, how many learnings it holds
and when the newest changed (about 100 bytes). If that matches the copy on disk, gitmem loads
from disk and skips the download. With 250 learnings, a second start went from about 920 KB
received to about 100 bytes.
- **Staleness:** any mismatch, corruption or unreadable file counts as a miss, and gitmem
downloads fresh. It never serves a copy it can't confirm is current.
- **Parallel starts:** when several processes start at once, one of them downloads and the others
wait for its copy, instead of all downloading at the same time.
- **`cache-flush`** skips the disk copy and rewrites it.
- **Where it lives:** `<gitmem dir>/cache/learnings-vectors-<hash>.json`. The gitmem dir is
`~/.gitmem`, or `GITMEM_DIR` if you set it. `<hash>` is derived from your Supabase URL, so
different stores never share a file.
- **What it contains:** a local copy of your learnings, including titles, descriptions and
embeddings. The file is written with mode `0600`, so only your user account can read it.
Deleting it is always safe; gitmem downloads again on the next start.
- **Opting out:** set `GITMEM_VECTOR_DISK_CACHE=0` (or `false`) to download on every start, as
before. (GIT-98)

### Known issues

- **A session that changes scar scores can still cause one re-download.** At `session_start`, gitmem
recalculates scar scores from their usage history. Any scar whose score changes gets a new
timestamp, and the next process start downloads the index again. Stores with no usage history
aren't affected. This goes away when per-row delta sync lands.
- **Scar usage and `confirm_scars` relevance aren't recorded on your project yet.** The usage write
still targets a table name that doesn't exist on customer stores. It's fixed separately (GIT-84)
and isn't part of this release.

## [1.8.0] - 2026-08-09

**No one loses any information.** Nothing is deleted, nothing is moved, nothing is overwritten.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitmem-mcp",
"version": "1.8.0",
"version": "1.9.0",
"mcpName": "io.github.gitmem-dev/gitmem",
"description": "Persistent learning memory for AI coding agents. Memory that compounds.",
"type": "module",
Expand All @@ -25,7 +25,7 @@
"test:all": "npm run test:unit && npm run test:smoke && npm run test:integration && npm run test:perf && npm run test:e2e",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "tsc",
"prepublishOnly": "rm -rf dist && tsc",
"release-status": "bash scripts/release-status.sh"
},
"dependencies": {
Expand Down
63 changes: 56 additions & 7 deletions src/services/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

import * as fs from "fs";
import * as path from "path";
import { isConfigured, loadScarsWithEmbeddings } from "./supabase-client.js";
import { isConfigured, loadScarsWithEmbeddings, getLearningsFingerprint, getSupabaseUrl } from "./supabase-client.js";
import { computeStoreKey, readVectorCache, writeVectorCache, tryAcquireDownloadLock, waitForLeaderCache } from "./vector-disk-cache.js";
import { getTableName } from "./tier.js";
import { getGitmemDir } from "./gitmem-dir.js";
import {
Expand Down Expand Up @@ -106,13 +107,51 @@ interface ScarWithEmbedding {
* NOTE: Now loads all learning types (scars, patterns, wins, anti-patterns),
* not just scars. This fixes the issue where ~64 patterns were being ignored.
*/
async function loadScarsFromSupabase(): Promise<{
async function loadScarsFromSupabase(options: { bypassDiskCache?: boolean } = {}): Promise<{
scars: ScarWithEmbedding[];
latestUpdatedAt: string | null;
source: "disk" | "network";
}> {
console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`);
const startTime = Date.now();

// GIT-98: ask the store what it holds (one row, a few hundred bytes) before
// paying for the bulk download. Observed BEFORE the download on purpose: if
// the store changes mid-load the cache is merely stale and the next start
// reloads, which is the safe direction.
const storeKey = computeStoreKey(getSupabaseUrl(), getTableName("learnings"));
const fingerprint = await getLearningsFingerprint();

if (!options.bypassDiskCache) {
const cached = readVectorCache<ScarWithEmbedding>(storeKey, fingerprint);
if (cached.hit) {
console.error(
`[startup] Vector cache HIT: ${cached.rows.length} learnings from disk in ${Date.now() - startTime}ms ` +
`(${cached.bytesOnDisk} bytes on disk, bulk download skipped)`
);
return { scars: cached.rows, latestUpdatedAt: fingerprint.latestUpdatedAt, source: "disk" };
}
console.error(`[startup] Vector cache MISS: ${cached.reason}`);
}

// Single-flight the download across processes. A fan-out starts N workers at
// once and a single store write invalidates the cache for all of them, so
// without this a miss costs N identical downloads.
let releaseLock = tryAcquireDownloadLock(storeKey);
if (!releaseLock && !options.bypassDiskCache && fingerprint.count >= 0) {
console.error("[startup] Another process is downloading the index — waiting for its cache");
const fromLeader = await waitForLeaderCache<ScarWithEmbedding>(storeKey, fingerprint);
if (fromLeader) {
console.error(
`[startup] Vector cache HIT (via leader): ${fromLeader.rows.length} learnings in ${Date.now() - startTime}ms, bulk download skipped`
);
return { scars: fromLeader.rows, latestUpdatedAt: fingerprint.latestUpdatedAt, source: "disk" };
}
console.error("[startup] Leader did not produce a usable cache — downloading");
}
releaseLock = releaseLock ?? (() => {});

console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`);

try {
// Load ALL learnings across projects — semantic similarity handles relevance
const learnings = await loadScarsWithEmbeddings<ScarWithEmbedding>(undefined, 500);
Expand All @@ -127,10 +166,19 @@ async function loadScarsFromSupabase(): Promise<{
// Get latest updated_at for staleness tracking
const latestUpdatedAt = learnings.length > 0 ? learnings[0].updated_at || null : null;

return { scars: learnings, latestUpdatedAt };
// Never cache an empty result: an empty download is far more often a
// transient failure than a genuinely empty store, and caching it would make
// the failure sticky.
if (learnings.length > 0) {
writeVectorCache(storeKey, fingerprint, learnings);
}

return { scars: learnings, latestUpdatedAt, source: "network" };
} catch (error) {
console.error("[startup] Failed to load learnings:", error);
return { scars: [], latestUpdatedAt: null };
return { scars: [], latestUpdatedAt: null, source: "network" };
} finally {
releaseLock();
}
}

Expand Down Expand Up @@ -576,8 +624,9 @@ export async function flushCache(_project?: Project): Promise<CacheFlushResult>
const previousCount = getLocalVectorSearch().getScarCount();

try {
// Load ALL fresh scars (cross-project)
const { scars, latestUpdatedAt } = await loadScarsFromSupabase();
// Load ALL fresh scars (cross-project). A flush is the user saying "I do not
// trust what you have" — bypass the disk cache (GIT-98) and rewrite it.
const { scars, latestUpdatedAt } = await loadScarsFromSupabase({ bypassDiskCache: true });

// Reinitialize the unified index
await reinitializeLocalSearch(scars, undefined, latestUpdatedAt || undefined);
Expand Down
Loading
Loading