Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
14 changes: 13 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 @@ -37,7 +37,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 +53,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 +105,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
42 changes: 20 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 @@ -180,16 +169,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 +411,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 +423,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 @@ -82,4 +82,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,38 @@ describe('AgentMemoryRepository', () => {
expect(read?.createdBySession).toBe('session-1');
});

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