Skip to content
Open
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
28 changes: 27 additions & 1 deletion packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export function setupAgentMemoryHandlers(
});
});

// Atomic insert-only create: fails when the (spaceId, key) already exists, so
// the management UI's "New Memory" flow can never silently overwrite. Edits
// go through agentMemory.write (upsert) instead.
messageHub.onRequest('agentMemory.create', async (payload: unknown) => {
const request = parseSpaceScopedRequest(payload);
return deps.memoryRepo.create({
spaceId: request.spaceId,
key: readRequiredString(payload, 'key'),
content: readRequiredString(payload, 'content'),
tags: readOptionalStringArray(payload, 'tags'),
createdBySession: null,
});
});

messageHub.onRequest('agentMemory.search', async (payload: unknown) => {
const request = parseSpaceScopedRequest(payload);
return deps.memoryRepo.search(
Expand All @@ -37,7 +51,9 @@ export function setupAgentMemoryHandlers(

messageHub.onRequest('agentMemory.read', async (payload: unknown) => {
const request = parseSpaceScopedRequest(payload);
return deps.memoryRepo.read(request.spaceId, readRequiredString(payload, 'key'));
return deps.memoryRepo.read(request.spaceId, readRequiredString(payload, 'key'), {
recordAccess: readOptionalBoolean(payload, 'recordAccess'),
});
});

messageHub.onRequest('agentMemory.delete', async (payload: unknown) => {
Expand All @@ -51,6 +67,9 @@ export function setupAgentMemoryHandlers(
query: readOptionalString(payload, 'query') ?? undefined,
limit: readOptionalInteger(payload, 'limit') ?? 50,
offset: readOptionalInteger(payload, 'offset') ?? 0,
// Management reads must not mutate agent telemetry (access_count /
// last_accessed_at), which drives core-ranking and stale-pruning.
recordAccess: false,
});
});
}
Expand Down Expand Up @@ -100,6 +119,13 @@ function readOptionalInteger(payload: unknown, key: string): number | undefined
return value;
}

function readOptionalBoolean(payload: unknown, key: string): boolean | undefined {
const value = readRecord(payload)[key];
if (value === undefined) return undefined;
if (typeof value !== 'boolean') throw new Error(`${key} must be a boolean.`);
return value;
}

function readRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Request payload must be an object.');
Expand Down
89 changes: 67 additions & 22 deletions packages/daemon/src/storage/repositories/agent-memory-repository.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import type { Database as BunDatabase } from 'bun:sqlite';
import type { AgentMemoryEntry, AgentMemorySearchResult } from '@hyperneo/shared';
import type { ReactiveDatabase } from '../reactive-database';

export interface AgentMemoryEntry {
key: string;
spaceId: string;
content: string;
tags: string[];
createdBySession: string | null;
createdAt: number;
updatedAt: number;
accessCount: number;
lastAccessedAt: number | null;
}

export interface AgentMemorySearchResult {
memory: AgentMemoryEntry;
rank: number;
}
// Wire shapes live in @hyperneo/shared so the web client and daemon share one
// source of truth. Re-exported here so existing daemon imports
// (`import { AgentMemoryEntry } from '.../agent-memory-repository'`) keep working.
export type { AgentMemoryEntry, AgentMemorySearchResult };

export interface AgentMemoryCoreEntry extends AgentMemoryEntry {
score: number;
Expand Down Expand Up @@ -148,6 +137,53 @@ export class AgentMemoryRepository {
return rowToEntry(row);
}

/**
* Atomically create a new memory. Unlike write() (which upserts on
* (spaceId, key)), this fails when a memory with the same key already exists,
* so a create can never silently overwrite an existing entry. Used by the
* management UI's "New Memory" flow; agent-authored writes still use write().
*/
create(params: {
spaceId: string;
key: string;
content: string;
tags?: string[];
createdBySession?: string | null;
}): AgentMemoryEntry {
const key = normalizeKey(params.key);
const content = normalizeContent(params.content);
const tags = normalizeTags(params.tags ?? []);
const now = Date.now();
const embeddingToken = crypto.randomUUID();

const row = this.db
.prepare(
`INSERT INTO space_agent_memory
(key, space_id, content, tags, created_by_session, created_at, updated_at, access_count, last_accessed_at, embedding_status, embedding_model, embedding_updated_at, embedding_error, embedding_revision, embedding_token)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, 'pending', NULL, NULL, NULL, 1, ?)
ON CONFLICT(space_id, key) DO NOTHING
RETURNING *`
)
.get(
key,
params.spaceId,
content,
serializeTags(tags),
params.createdBySession ?? null,
now,
now,
embeddingToken
) as AgentMemoryRow | undefined;

if (!row) {
throw new Error(`A memory with the key "${key}" already exists in this space.`);
}

this.updateEmbedding(row);
this.reactiveDb?.notifyChange('space_agent_memory');
return rowToEntry(row);
}

read(
spaceId: string,
key: string,
Expand Down Expand Up @@ -180,16 +216,21 @@ export class AgentMemoryRepository {

async list(
spaceId: string,
options?: { query?: string; limit?: number; offset?: number }
options?: { query?: string; limit?: number; offset?: number; recordAccess?: boolean }
): Promise<AgentMemoryEntry[]> {
const limit = normalizeLimit(options?.limit ?? 50, 100);
const offset = Math.max(0, Math.trunc(options?.offset ?? 0));
const query = options?.query?.trim();

if (query) {
return (await this.searchWithOptions(spaceId, query, { limit, offset, maxLimit: 100 })).map(
rowToEntry
);
return (
await this.searchWithOptions(spaceId, query, {
limit,
offset,
maxLimit: 100,
recordAccess: options?.recordAccess,
})
).map(rowToEntry);
}

const rows = this.db
Expand Down Expand Up @@ -417,7 +458,7 @@ export class AgentMemoryRepository {
private async searchWithOptions(
spaceId: string,
query: string,
options?: { limit?: number; offset?: number; maxLimit?: number }
options?: { limit?: number; offset?: number; maxLimit?: number; recordAccess?: boolean }
): Promise<AgentMemorySearchRow[]> {
const ftsQuery = buildFtsQuery(query);
const limit = normalizeLimit(options?.limit ?? 10, options?.maxLimit ?? 20);
Expand All @@ -429,7 +470,11 @@ export class AgentMemoryRepository {
const vectorRows = await this.searchVector(spaceId, query, poolLimit);
const rows = mergeRankedRows(ftsRows, vectorRows).slice(offset, offset + limit);

if (rows.length > 0) {
// `recordAccess` defaults to true: an agent recalling a memory via
// `search()` is a genuine access that should refresh its core-ranking /
// staleness telemetry. Management reads (`list` with a query) pass
// `recordAccess: false` so browsing the panel never mutates telemetry.
if (options?.recordAccess !== false && rows.length > 0) {
const now = Date.now();
const bump = this.db.prepare(
`UPDATE space_agent_memory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ describe('agent memory RPC handlers', () => {
expect(writes[0]?.tags).toBeUndefined();
});

test('create delegates to the repository insert-only create', async () => {
const { messageHub, handlers } = createMessageHubStub();
const creates: Array<Record<string, unknown>> = [];
setupAgentMemoryHandlers(messageHub as never, {
memoryRepo: {
create: (params: Record<string, unknown>) => {
creates.push(params);
return params;
},
} as never,
});

await handlers.get('agentMemory.create')?.({
spaceId: 'space-a',
key: 'k',
content: 'c',
tags: ['t'],
});

expect(creates[0]).toMatchObject({ spaceId: 'space-a', key: 'k', content: 'c', tags: ['t'] });
expect(creates[0]?.createdBySession).toBeNull();
});

test('write ignores caller-supplied createdBySession', async () => {
const { messageHub, handlers } = createMessageHubStub();
const writes: Array<Record<string, unknown>> = [];
Expand All @@ -82,4 +105,42 @@ describe('agent memory RPC handlers', () => {

expect(writes[0]?.createdBySession).toBeNull();
});

test('list is a read-only management query (recordAccess: false)', async () => {
const { messageHub, handlers } = createMessageHubStub();
const calls: Array<Record<string, unknown>> = [];
setupAgentMemoryHandlers(messageHub as never, {
memoryRepo: {
list: (_spaceId: string, options: Record<string, unknown>) => {
calls.push(options);
return [];
},
} as never,
});

await handlers.get('agentMemory.list')?.({ spaceId: 'space-a', query: 'conventions' });

// Management reads must not mutate access_count / last_accessed_at.
expect(calls[0]?.recordAccess).toBe(false);
});

test('read forwards recordAccess from the payload', async () => {
const { messageHub, handlers } = createMessageHubStub();
const calls: Array<{ recordAccess: boolean | undefined }> = [];
setupAgentMemoryHandlers(messageHub as never, {
memoryRepo: {
read: (_spaceId: string, _key: string, options?: { recordAccess?: boolean }) => {
calls.push({ recordAccess: options?.recordAccess });
return null;
},
} as never,
});

await handlers.get('agentMemory.read')?.({ spaceId: 'space-a', key: 'k', recordAccess: false });
await handlers.get('agentMemory.read')?.({ spaceId: 'space-a', key: 'k' });

// Explicit false is forwarded; absent leaves the repo default (record).
expect(calls[0]?.recordAccess).toBe(false);
expect(calls[1]?.recordAccess).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,53 @@ describe('AgentMemoryRepository', () => {
expect(read?.createdBySession).toBe('session-1');
});

test('create atomically inserts and rejects on a duplicate key', () => {
repo.write({ spaceId: 'space-a', key: 'dup', content: 'first' });

// Same (spaceId, key) must not silently overwrite.
expect(() => repo.create({ spaceId: 'space-a', key: 'dup', content: 'second' })).toThrow(
'already exists'
);
expect(repo.read('space-a', 'dup', { recordAccess: false })?.content).toBe('first');

// A genuinely new key creates normally.
const created = repo.create({ spaceId: 'space-a', key: 'fresh', content: 'new' });
expect(created.key).toBe('fresh');
expect(repo.read('space-a', 'fresh', { recordAccess: false })?.content).toBe('new');
});

test('filtered list does not record access when recordAccess is false', async () => {
repo.write({
spaceId: 'space-a',
key: 'conventions.access',
content: 'Management reads must not bump access telemetry.',
tags: ['telemetry'],
});

// Management read (recordAccess: false): the hybrid backend returns the
// row but must leave access_count / last_accessed_at untouched so browsing
// the panel never skews core-ranking or stale-pruning.
const readonly = await repo.list('space-a', {
query: 'access telemetry',
recordAccess: false,
});
expect(readonly).toHaveLength(1);

let row = db
.prepare(`SELECT access_count, last_accessed_at FROM space_agent_memory WHERE key = ?`)
.get('conventions.access') as { access_count: number; last_accessed_at: number | null };
expect(row.access_count).toBe(0);
expect(row.last_accessed_at).toBeNull();

// Default filtered list (recordAccess defaults to true) still records
// access, matching the agent-facing search semantic.
await repo.list('space-a', { query: 'access telemetry' });
row = db
.prepare(`SELECT access_count FROM space_agent_memory WHERE key = ?`)
.get('conventions.access') as { access_count: number };
expect(row.access_count).toBe(1);
});

test('search returns FTS-ranked results', async () => {
repo.write({
spaceId: 'space-a',
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export * from './types/tools.ts';
export * from './types/app-mcp-server.ts';
export * from './types/mcp-enablement.ts';
export * from './types/skills.ts';
export * from './types/memory.ts';
export * from './types/reference.ts';
export * from './types/provider-record.ts';
export * from './live-query-types.ts';
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/types/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Agent memory wire types.
*
* These mirror the on-the-wire shapes returned by the `agentMemory.*` RPC
* handlers (`packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts`),
* which delegate to `AgentMemoryRepository`. The daemon repository imports
* these types from here so there is a single source of truth for the shape
* clients receive.
*
* Note: there is intentionally no `id` field — the internal DB row id (and all
* embedding-related columns) are stripped before crossing the wire. A memory
* is uniquely identified within a space by its `key`.
*/

export interface AgentMemoryEntry {
key: string;
spaceId: string;
content: string;
tags: string[];
createdBySession: string | null;
createdAt: number;
updatedAt: number;
accessCount: number;
lastAccessedAt: number | null;
}

/**
* A single ranked search hit. `rank` is a fused reciprocal-rank-fusion score
* combining BM25 (FTS) and vector similarity — treat it as an opaque ordering
* value, not a normalized 0-1 score.
*/
export interface AgentMemorySearchResult {
memory: AgentMemoryEntry;
rank: number;
}
Loading
Loading