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:
Madan Kumar
2026-09-11 22:36:17 +05:30
committed by GitHub
parent 7186eb5608
commit 0bbef55a70
4 changed files with 122 additions and 1 deletions
+14
View File
@@ -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');
});
});