mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
fix(sqlite): skip empty-title observations at capture (#3176)
Empty-title observations are malformed, low-signal rows that fill the recency-based recall window without adding any facts. Skip them in storeObservations so they stop accumulating in the observations table. The single-observation storeObservation wrapper now requires a non-empty title (throws instead of returning an undefined id), and its /api/memory/save caller falls back to the text-derived title for whitespace-only titles so a blank title never reaches the store. Refs #3163
This commit is contained in:
@@ -2841,6 +2841,13 @@ export class SessionStore {
|
||||
overrideTimestampEpoch?: number,
|
||||
generatedByModel?: string
|
||||
): { id: number; createdAtEpoch: number } {
|
||||
// storeObservations skips empty-title rows, which would leave no id to return here.
|
||||
// This wrapper stores exactly one observation, so require a title up front rather than
|
||||
// returning an undefined id.
|
||||
if (!observation.title || observation.title.trim() === '') {
|
||||
throw new Error('storeObservation requires a non-empty title');
|
||||
}
|
||||
|
||||
const result = this.storeObservations(
|
||||
memorySessionId,
|
||||
project,
|
||||
@@ -2956,6 +2963,13 @@ export class SessionStore {
|
||||
);
|
||||
|
||||
for (const observation of observations) {
|
||||
// Skip observations with an empty title. They're malformed, low-signal rows that
|
||||
// just take up space in the recency-based recall window without adding any facts.
|
||||
if (!observation.title || observation.title.trim() === '') {
|
||||
logger.debug('DB', 'Skipping observation with empty title');
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
|
||||
const inserted = obsStmt.get(
|
||||
memorySessionId,
|
||||
|
||||
@@ -45,7 +45,9 @@ export class MemoryRoutes extends BaseRouteHandler {
|
||||
|
||||
const observation = {
|
||||
type: 'discovery', // Use existing valid type
|
||||
title: title || text.substring(0, 60).trim() + (text.length > 60 ? '...' : ''),
|
||||
// A whitespace-only title is truthy but blank once trimmed; fall back to the text so we
|
||||
// never hand storeObservation an empty title.
|
||||
title: title?.trim() || text.substring(0, 60).trim() + (text.length > 60 ? '...' : ''),
|
||||
subtitle: 'Manual memory',
|
||||
facts: [] as string[],
|
||||
narrative: text,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../../src/services/sqlite/SessionStore.js';
|
||||
|
||||
function obs(overrides: Partial<Parameters<SessionStore['storeObservation']>[2]> = {}) {
|
||||
return {
|
||||
type: 'discovery',
|
||||
title: 'A Real Observation',
|
||||
subtitle: null,
|
||||
facts: [] as string[],
|
||||
narrative: 'Some narrative content',
|
||||
concepts: [] as string[],
|
||||
files_read: [] as string[],
|
||||
files_modified: [] as string[],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionStore.storeObservations empty-title handling', () => {
|
||||
let store: SessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new SessionStore(':memory:');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
// observations.memory_session_id is an enforced FK to sdk_sessions; register it first.
|
||||
function session(memorySessionId: string): string {
|
||||
const id = store.createSDKSession(`content-${memorySessionId}`, 'project', 'prompt');
|
||||
store.updateMemorySessionId(id, memorySessionId);
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
it('skips observations with empty, whitespace-only, or null titles', () => {
|
||||
const mem = session('mem-empty-title');
|
||||
|
||||
const result = store.storeObservations(
|
||||
mem,
|
||||
'project',
|
||||
[
|
||||
obs({ title: 'A Real Observation' }),
|
||||
obs({ title: '' }),
|
||||
obs({ title: ' ' }),
|
||||
obs({ title: null }),
|
||||
],
|
||||
null,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.observationIds.length).toBe(1);
|
||||
|
||||
const stored = store.getObservationsForSession(mem);
|
||||
expect(stored.length).toBe(1);
|
||||
expect(stored[0].title).toBe('A Real Observation');
|
||||
});
|
||||
|
||||
it('stores every observation when all titles are present', () => {
|
||||
const mem = session('mem-all-titled');
|
||||
|
||||
const result = store.storeObservations(
|
||||
mem,
|
||||
'project',
|
||||
[obs({ title: 'First' }), obs({ title: 'Second' })],
|
||||
null,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.observationIds.length).toBe(2);
|
||||
expect(store.getObservationsForSession(mem).length).toBe(2);
|
||||
});
|
||||
|
||||
it('storeObservation (single) throws on an empty title instead of returning an undefined id', () => {
|
||||
const mem = session('mem-single-empty');
|
||||
expect(() => store.storeObservation(mem, 'project', obs({ title: '' }))).toThrow(/non-empty title/);
|
||||
expect(() => store.storeObservation(mem, 'project', obs({ title: null }))).toThrow(/non-empty title/);
|
||||
});
|
||||
|
||||
it('storeObservation (single) stores and returns a real id for a titled observation', () => {
|
||||
const mem = session('mem-single-ok');
|
||||
const result = store.storeObservation(mem, 'project', obs({ title: 'Kept' }));
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -199,4 +199,22 @@ describe('MemoryRoutes — POST /api/memory/save (#2116)', () => {
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
expect(mockStoreObservation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a text-derived title when the title is whitespace-only', () => {
|
||||
const handler = buildHandler();
|
||||
const { req, res } = createMockReqRes({ text: 'the memory body text', title: ' ' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockStoreObservation).toHaveBeenCalledTimes(1);
|
||||
// A whitespace-only title must not reach storeObservation (it would throw); use the text.
|
||||
expect(storeObservationCalls[0][2].title).toBe('the memory body text');
|
||||
});
|
||||
|
||||
it('uses the provided title (trimmed) when it has content', () => {
|
||||
const handler = buildHandler();
|
||||
const { req, res } = createMockReqRes({ text: 'body', title: ' My Title ' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(storeObservationCalls[0][2].title).toBe('My Title');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user