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
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.10.0] - 2026-09-21

**Sessions that weren't being saved now save.** On your own Supabase project, a `session_close` was
rejected by the database, and the session never saved, whenever it carried sub-agent observations,
child-agent records, or a Claude Code session id. gitmem attaches that id whenever it finds the
session's transcript, which is most Claude Code CLI and desktop sessions. The close reported
`FAILED`, but the session was gone. Those closes now save. (GIT-110)

**Re-running `setup.sql` is recommended, not required. It's safe to run more than once.** If you
skip it, everything still works. The one difference: the scar-scoring function keeps its old
behaviour, rewriting every scored scar on each `session_start`. That makes the next process start
re-download your embedding index instead of reading it from disk. Re-running `setup.sql` installs
the version that only rewrites scars whose score actually changed.

**`health` will probably show more failures than before. That isn't a regression.** Those writes
were already failing; `health` used to count them as successes. The ones that remain are listed
under *Known issues*.

This release is for Pro stores, meaning your own Supabase project. Before it, gitmem assumed columns
and tables that a project set up from `setup.sql` doesn't have. Anything written to them failed, and
in the worst case a whole session was lost. gitmem now writes only what your project has, whether or
not you've re-run `setup.sql`.

### Fixed

- **Session closes were being lost (GIT-110).** If a Claude Code CLI or desktop session found its
transcript, or recorded sub-agent observations or child agents, `session_close` sent columns your
project doesn't have. The database rejected the whole write, and the session was never saved (the
close did report `FAILED`). gitmem now checks once per process which optional columns your
project has, using a
small read-only query, and writes only those.
- **Scar usage is recorded again.** Usage was written to a table name that doesn't exist on your
project, so every usage record was lost. That also meant the scar-scoring and blindspot features
had nothing to work from. (GIT-84)
- **`confirm_scars` relevance is now saved.** Which surfaced scars you applied, and how relevant you
rated each one, is now stored in the recall row of `gitmem_query_metrics`, inside its existing
`metadata` field (`memories_applied`, `memory_relevance`). Before, it went to a column that doesn't
exist and was matched against the wrong kind of value, so nothing was ever saved. (GIT-109)
- **`archive_learning` now archives.** It also wrote an `archived_at` column your project doesn't
have, so the update was rejected and the scar stayed active. The column is now written only where
it exists; the row's `updated_at` still records when it was archived.
- **Analytics, the `session_start` insights and the `session_close` blindspot check work again.**
They asked the usage table for title and severity columns it has never had. Those now come from
your learnings. (GIT-108)
- **`health` reports failed writes as failures.** It used to count as a success any write that
failed but didn't throw an error. That's why the problems above went unnoticed. (GIT-104)
- **`setup.sql` can be re-run.** A second run used to fail partway through. (GIT-84)

### Changed

- **A/B testing of scar enforcement variants is off outside dev tier.** It depends on tables
`setup.sql` doesn't create, so on your project every variant read and write failed. (GIT-106)
- **Transcripts are uploaded only where the project is set up for them.** `setup.sql` creates
neither the Storage bucket nor the table transcript upload needs.
- **`hook-scars.json` is now readable only by your user account** (mode `0600`), like the embedding
cache. It holds learning text. Existing files are tightened on the next start. (GIT-109)
- **The scar-scoring function only rewrites scars whose score changed.** A scored scar stays cached
on disk across sessions unless its score actually moves. This needs the re-run `setup.sql`.
(GIT-84)

### Known issues

- **One `metrics` failure at `session_start` can appear in `health`.** A metrics row sometimes
arrives before its session row exists. Nothing is lost except that one metrics row. (GIT-73)
- **A knowledge-graph link for new threads fails in `health`.** It writes a thread id where the
database expects a UUID. (GIT-105)

## [1.9.0] - 2026-09-20

**No schema change. You do not need to re-run `setup.sql`.**
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitmem-mcp",
"version": "1.9.0",
"version": "1.10.0",
"mcpName": "io.github.gitmem-dev/gitmem",
"description": "Persistent learning memory for AI coding agents. Memory that compounds.",
"type": "module",
Expand Down
10 changes: 9 additions & 1 deletion schema/setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,12 @@ BEGIN
updated_at = NOW()
FROM usage_stats us
WHERE l.id = us.scar_id
AND COALESCE(l.is_active, true) = true;
AND COALESCE(l.is_active, true) = true
-- Only touch rows whose multiplier actually changes. Rewriting unchanged
-- rows bumped updated_at on every session_start, which invalidates every
-- client's on-disk embedding cache (GIT-98) for no reason.
AND l.decay_multiplier IS DISTINCT FROM
GREATEST(0.1, 1.0 - (us.times_dismissed::FLOAT / us.times_surfaced::FLOAT) * 0.8);

GET DIAGNOSTICS v_updated = ROW_COUNT;

Expand Down Expand Up @@ -529,9 +534,12 @@ CREATE TABLE IF NOT EXISTS gitmem_license_activations (
ALTER TABLE gitmem_licenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE gitmem_license_activations ENABLE ROW LEVEL SECURITY;

-- Drop-then-create so setup.sql can be re-applied (CREATE POLICY has no IF NOT EXISTS)
DROP POLICY IF EXISTS "Service role full access" ON gitmem_licenses;
CREATE POLICY "Service role full access" ON gitmem_licenses
FOR ALL USING (auth.role() = 'service_role');

DROP POLICY IF EXISTS "Service role full access" ON gitmem_license_activations;
CREATE POLICY "Service role full access" ON gitmem_license_activations
FOR ALL USING (auth.role() = 'service_role');

Expand Down
14 changes: 10 additions & 4 deletions src/services/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,15 @@ export async function queryScarUsageByDateRange(
surfaced_at: `gte.${startDate}`,
};

return directQueryAll<ScarUsageRecord>("scar_usage", {
select: "scar_id,scar_title,scar_severity,agent,reference_type,execution_successful,surfaced_at",
// GIT-108: the usage table has no scar_title/scar_severity columns (selecting
// them was a 400 on every store). They come back null here and are filled
// from the learnings table by enrichScarUsageTitles(), which every caller runs.
const rows = await directQueryAll<Omit<ScarUsageRecord, "scar_title" | "scar_severity">>(getTableName("scar_usage"), {
select: "scar_id,agent,reference_type,execution_successful,surfaced_at",
filters,
order: "surfaced_at.desc",
});
return rows.map((r) => ({ ...r, scar_title: null, scar_severity: null }));
}
);

Expand Down Expand Up @@ -244,8 +248,10 @@ export async function queryRepeatMistakes(
}

/**
* Resolve scar titles and severities from the learnings table for scar_usage
* records that have null/missing title data.
* Resolve scar titles and severities from the learnings table.
*
* Required after queryScarUsageByDateRange(): the usage table stores only
* scar_id, so every record arrives with null title/severity (GIT-108).
*/
export async function enrichScarUsageTitles(
usages: ScarUsageRecord[]
Expand Down
3 changes: 2 additions & 1 deletion src/services/behavioral-decay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { isConfigured, safeInFilter } from "./supabase-client.js";
import { getTableName } from "./tier.js";

const SUPABASE_URL = process.env.SUPABASE_URL || "";
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY || "";
Expand Down Expand Up @@ -98,7 +99,7 @@ export async function fetchDismissalCounts(

try {
// Query scar_usage for the given scar IDs (last 90 days)
const url = new URL(`${SUPABASE_REST_URL}/scar_usage`);
const url = new URL(`${SUPABASE_REST_URL}/${getTableName("scar_usage")}`);
url.searchParams.set("select", "scar_id,reference_type");
url.searchParams.set("scar_id", safeInFilter(scarIds));
url.searchParams.set("surfaced_at", `gte.${new Date(Date.now() - 90 * 86400000).toISOString()}`);
Expand Down
50 changes: 29 additions & 21 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,29 +180,37 @@ export class CacheService {
* Set cached result
*/
async setResult<T>(key: string, data: T, ttlMs: number): Promise<void> {
if (!this.enabled) return;

try {
const now = Date.now();
const entry: CacheEntry<T> = {
key,
created_at: now,
expires_at: now + ttlMs,
data,
};

const filename = this.keyToFilename(key);
const filepath = join(this.resultsDir, filename);
const content = JSON.stringify(entry, null, 2);

writeFileSync(filepath, content, { mode: 0o600 });
console.error(`[cache] SET: ${key} (TTL: ${ttlMs}ms)`);
await this.writeResult(key, data, ttlMs);
} catch (error) {
console.warn(`[cache] Error writing ${key}:`, error);
// Don't disable cache on write errors - might be transient
}
}

/**
* Like setResult, but rejects on a write error. Used under the effect
* tracker so cache write failures are counted (GIT-104).
*/
private async writeResult<T>(key: string, data: T, ttlMs: number): Promise<void> {
if (!this.enabled) return;

const now = Date.now();
const entry: CacheEntry<T> = {
key,
created_at: now,
expires_at: now + ttlMs,
data,
};

const filename = this.keyToFilename(key);
const filepath = join(this.resultsDir, filename);
const content = JSON.stringify(entry, null, 2);

writeFileSync(filepath, content, { mode: 0o600 });
console.error(`[cache] SET: ${key} (TTL: ${ttlMs}ms)`);
}

/**
* Convenience method: get or fetch scar search results
*/
Expand All @@ -224,7 +232,7 @@ export class CacheService {

// Cache the result (tracked, fire-and-forget)
getEffectTracker().track("cache_set", "scar_search", () =>
this.setResult(key, data, TTL.SCAR_SEARCH)
this.writeResult(key, data, TTL.SCAR_SEARCH)
);

return { data, cache_hit: false };
Expand All @@ -250,7 +258,7 @@ export class CacheService {

// Cache the result (tracked, fire-and-forget)
getEffectTracker().track("cache_set", "decisions", () =>
this.setResult(key, data, TTL.DECISIONS)
this.writeResult(key, data, TTL.DECISIONS)
);

return { data, cache_hit: false };
Expand All @@ -276,7 +284,7 @@ export class CacheService {

// Cache the result (tracked, fire-and-forget)
getEffectTracker().track("cache_set", "wins", () =>
this.setResult(key, data, TTL.WINS)
this.writeResult(key, data, TTL.WINS)
);

return { data, cache_hit: false };
Expand Down Expand Up @@ -314,7 +322,7 @@ export class CacheService {

const data = await fetcher();
getEffectTracker().track("cache_set", "sessions", () =>
this.setResult(key, data, TTL.SESSIONS)
this.writeResult(key, data, TTL.SESSIONS)
);
return { data, cache_hit: false };
}
Expand All @@ -337,7 +345,7 @@ export class CacheService {

const data = await fetcher();
getEffectTracker().track("cache_set", "scar_usage", () =>
this.setResult(key, data, TTL.SCAR_USAGE)
this.writeResult(key, data, TTL.SCAR_USAGE)
);
return { data, cache_hit: false };
}
Expand Down
67 changes: 49 additions & 18 deletions src/services/effect-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ class RingBuffer<T> {
}
}

// ---------------------------------------------------------------------------
// Result inspection
// ---------------------------------------------------------------------------

/**
* If a resolved value reports its own failure (`{ success: false }`), return a
* message describing it; otherwise null. Prefers the value's error, then
* message, then display text.
*/
function reportedFailure(value: unknown): string | null {
if (typeof value !== "object" || value === null) return null;
const v = value as Record<string, unknown>;
if (v.success !== false) return null;
for (const key of ["error", "message", "display"] as const) {
const field = v[key];
if (field instanceof Error) return field.message;
if (typeof field === "string" && field.length > 0) return field;
}
return "resolved with success: false";
}

// ---------------------------------------------------------------------------
// EffectTracker
// ---------------------------------------------------------------------------
Expand All @@ -106,7 +127,10 @@ export class EffectTracker {
* Wrap an async fire-and-forget operation for tracking.
*
* - On success: records success + duration, returns the result.
* - On failure: records failure + error message, returns undefined.
* - On a result of the form `{ success: false, ... }`: records a failure
* (GIT-104 — many tools catch their own errors and resolve with that
* shape), and still returns the result to the caller.
* - On rejection: records failure + error message, returns undefined.
* The error is **not** rethrown — this is intentionally fire-and-forget.
*/
track<T>(
Expand All @@ -118,29 +142,36 @@ export class EffectTracker {
const stats = this.getOrCreatePath(path);
stats.attempted++;

const recordFailure = (errorMsg: string) => {
const duration = Date.now() - start;
stats.failed++;
stats.totalDurationMs += duration;
stats.lastFailure = {
error: errorMsg,
timestamp: new Date().toISOString(),
};
this.failures.push({
path,
target,
error: errorMsg,
timestamp: new Date().toISOString(),
durationMs: duration,
});
};

return fn().then(
(result) => {
const duration = Date.now() - start;
const reported = reportedFailure(result);
if (reported !== null) {
recordFailure(reported);
return result;
}
stats.succeeded++;
stats.totalDurationMs += duration;
stats.totalDurationMs += Date.now() - start;
return result;
},
(error) => {
const duration = Date.now() - start;
const errorMsg = error instanceof Error ? error.message : String(error);
stats.failed++;
stats.totalDurationMs += duration;
stats.lastFailure = {
error: errorMsg,
timestamp: new Date().toISOString(),
};
this.failures.push({
path,
target,
error: errorMsg,
timestamp: new Date().toISOString(),
durationMs: duration,
});
recordFailure(error instanceof Error ? error.message : String(error));
return undefined;
},
);
Expand Down
Loading
Loading