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
18 changes: 16 additions & 2 deletions apps/memos-local-plugin/agent-contract/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export interface MemoryCore {
archiveWorldModel(id: string): Promise<WorldModelDTO | null>;
/** Reverse of {@link archiveWorldModel}. */
unarchiveWorldModel(id: string): Promise<WorldModelDTO | null>;
listEpisodes(input: { sessionId?: SessionId; limit?: number; offset?: number }): Promise<EpisodeId[]>;
listEpisodes(input: { sessionId?: SessionId; limit?: number; offset?: number; includeAllNamespaces?: boolean }): Promise<EpisodeId[]>;
/**
* Like `listEpisodes` but returns rich per-row metadata the viewer
* needs to render its task list without a second round trip
Expand Down Expand Up @@ -435,6 +435,20 @@ export interface MemoryCore {
toolNames?: readonly string[];
limit?: number;
offset?: number;
/**
* When true, ignore the (turn-scoped) active namespace and return
* rows from every namespace. Viewer callers set this so the Logs
* page and metrics aggregations stay stable across profile flips
* (#2131); matches the pattern already used by `listTraces` /
* `listSkills` / `listPolicies`.
*
* Note: the write path currently does not stamp per-namespace
* owner columns on `api_logs`, so this flag is a no-op today —
* every listing is effectively cross-namespace. Keeping the
* parameter formalises the contract now so a future per-namespace
* write can be added without breaking viewer callers.
*/
includeAllNamespaces?: boolean;
}): Promise<{ logs: ApiLogDTO[]; total: number }>;

// ── skills ──
Expand Down Expand Up @@ -504,7 +518,7 @@ export interface MemoryCore {
* Aggregate counts for the viewer's Analytics tab. `days` controls
* the window the `dailyWrites` histogram covers.
*/
metrics(input?: { days?: number }): Promise<{
metrics(input?: { days?: number; includeAllNamespaces?: boolean }): Promise<{
total: number;
writesToday: number;
sessions: number;
Expand Down
50 changes: 42 additions & 8 deletions apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3188,15 +3188,30 @@ export function createMemoryCore(
sessionId?: SessionId;
limit?: number;
offset?: number;
includeAllNamespaces?: boolean;
}): Promise<EpisodeId[]> {
ensureLive();
const rows = handle.repos.episodes.list({
sessionId: input.sessionId,
limit: input.limit ?? 50,
offset: input.offset ?? 0,
});
return rows
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
if (input.includeAllNamespaces) {
// Every row passes the visibility filter, so repo-level paging
// is both correct and cheap.
return handle.repos.episodes
.list({ sessionId: input.sessionId, limit, offset })
.map((r: EpisodeRow) => r.id as EpisodeId);
}
// Namespace-scoped path: paging at the repo level would apply
// `limit` before the visibility filter runs, silently under-filling
// pages (callers can't tell a short page from end-of-data). Fetch
// the widest window the repo allows, filter, then page in memory —
// same idiom as `listEpisodeRows` / `countEpisodes`. The repo
// clamps the fetch window to 500 rows (`clampLimit`), so scoped
// paging is exact within the newest 500 episodes; beyond that the
// same shared limitation applies to the sibling list/count methods.
return handle.repos.episodes
.list({ sessionId: input.sessionId, limit: 100_000 })
.filter((r: EpisodeRow) => visibleToCurrent(r))
.slice(offset, offset + limit)
.map((r: EpisodeRow) => r.id as EpisodeId);
}

Expand Down Expand Up @@ -3399,8 +3414,16 @@ export function createMemoryCore(
toolNames?: readonly string[];
limit?: number;
offset?: number;
includeAllNamespaces?: boolean;
}): Promise<{ logs: ApiLogDTO[]; total: number }> {
ensureLive();
// `includeAllNamespaces` is accepted for contract symmetry with the
// other viewer list* methods (#2131). The `api_logs` write path does
// not currently stamp per-namespace owner columns, so every row is
// effectively cross-namespace regardless of this flag. Callers that
// fan into viewer aggregations still pass `true` so their intent is
// explicit if a per-namespace write path is added later.
void input?.includeAllNamespaces;
const limit = Math.max(1, Math.min(500, input?.limit ?? 50));
const offset = Math.max(0, input?.offset ?? 0);
const rows = handle.repos.apiLogs.list({
Expand Down Expand Up @@ -3763,7 +3786,7 @@ export function createMemoryCore(
});
}

async function metrics(input?: { days?: number }): Promise<{
async function metrics(input?: { days?: number; includeAllNamespaces?: boolean }): Promise<{
total: number;
writesToday: number;
sessions: number;
Expand Down Expand Up @@ -3801,6 +3824,14 @@ export function createMemoryCore(
const oneDayMs = 86_400_000;
const sinceMs = now - days * oneDayMs;

// NOTE: `traces` is fetched unfiltered (all namespaces) below so
// that `sessions` / `writesToday` / `embeddings` / `dailyWrites`
// populate the viewer chart even after a turn from a different
// profile flips the active namespace (#2131). Only `total`
// (totalTurns) respects `includeAllNamespaces`. If a per-namespace
// scoping is ever needed for these derived fields (e.g. per-agent
// views), thread `visibilityWhere(activeNamespace)` through the
// repo query the same way `countTurns` does.
const traces = handle.repos.traces.list({ limit: 10_000 });
const sessions = new Set<string>();
let writesToday = 0;
Expand Down Expand Up @@ -3908,9 +3939,12 @@ export function createMemoryCore(
// shows: 1 user turn = 1 memory (regardless of how many tool calls
// / sub-steps were captured for that turn).
// Apply namespace visibility so the count matches the filtered list.
// Viewer callers pass `includeAllNamespaces` — `activeNamespace` is
// rewritten by every turn/session, so binding this count to it made
// the dashboard total collapse whenever another profile's turn ran.
const totalTurns = handle.repos.traces.countTurns(
{},
visibilityWhere(activeNamespace),
input?.includeAllNamespaces ? undefined : visibilityWhere(activeNamespace),
);

return {
Expand Down
16 changes: 14 additions & 2 deletions apps/memos-local-plugin/server/routes/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void {
routes.set("GET /api/v1/metrics", async (ctx) => {
const raw = ctx.url.searchParams.get("days");
const days = raw ? Number(raw) : undefined;
// Viewer analytics cover the whole local database — see overview.ts
// for why viewer reads must not follow the turn-scoped namespace.
return await deps.core.metrics({
days: Number.isFinite(days) ? days : undefined,
includeAllNamespaces: true,
});
});

Expand Down Expand Up @@ -101,7 +104,16 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void {
// tools, and their timings reflect background work rather than
// response latency.
const PUBLIC_API_LOG_TOOLS = new Set(["memos_search", "memory_search", "memory_add"]);
const { logs } = await deps.core.listApiLogs({ limit: 5_000, offset: 0 });
// Same convention as the `listTraces` call below (#2131): the tool
// panel folds api_logs entries and trace tool-calls into a single
// aggregation, so both feeds must be scoped identically. Passing
// `includeAllNamespaces: true` here keeps them aligned even if a
// future per-namespace write path lands on api_logs.
const { logs } = await deps.core.listApiLogs({
limit: 5_000,
offset: 0,
includeAllNamespaces: true,
});
for (const lg of logs) {
if (lg.calledAt < sinceMs) continue;
if (!PUBLIC_API_LOG_TOOLS.has(lg.toolName)) continue;
Expand All @@ -112,7 +124,7 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void {
// web / whatever the agent ran. Fold them in with the api_logs
// rows so the panel answers "is anything slow?" regardless of
// whether the slowness was internal or user-visible.
const traces = await deps.core.listTraces({ limit: 2_000, offset: 0 });
const traces = await deps.core.listTraces({ limit: 2_000, offset: 0, includeAllNamespaces: true });
for (const tr of traces) {
if (tr.ts < sinceMs) continue;
for (const tc of tr.toolCalls ?? []) {
Expand Down
16 changes: 11 additions & 5 deletions apps/memos-local-plugin/server/routes/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,25 @@ export function registerOverviewRoutes(routes: Routes, deps: ServerDeps): void {
// headless callers. Routing the ping through the viewer's mount
// hook keeps the semantics honest (a browser actually opened
// the page) and is naturally deduped by browser tab lifetime.
// The viewer is a local single-user admin surface: its aggregate
// counts must reflect the whole database, not the namespace of
// whichever agent profile processed the most recent turn. The core
// rewrites its active namespace on every turn/session, so scoped
// reads here made the dashboard "drift to zero" as soon as a
// message arrived (#2131). Same convention as diag.ts / session.ts.
const [health, episodeIds, skills, policies, worldModels, metrics] =
await Promise.all([
deps.core.health(),
deps.core.listEpisodes({ limit: 5_000 }),
deps.core.listSkills({ limit: 500 }),
deps.core.listEpisodes({ limit: 5_000, includeAllNamespaces: true }),
deps.core.listSkills({ limit: 500, includeAllNamespaces: true }),
// Core only exposes `listPolicies({ status? })`; the viewer wants
// the grand total + per-status so we request the biggest page and
// break it down here. 500 is plenty — fresh installs have dozens.
deps.core.listPolicies({ limit: 500 }),
deps.core.listWorldModels({ limit: 500 }),
deps.core.listPolicies({ limit: 500, includeAllNamespaces: true }),
deps.core.listWorldModels({ limit: 500, includeAllNamespaces: true }),
// `metrics.total` is the grand total of traces — cheaper than a
// dedicated count RPC and already cached by the core.
deps.core.metrics({ days: 1 }),
deps.core.metrics({ days: 1, includeAllNamespaces: true }),
]);

const skillStats = {
Expand Down
7 changes: 6 additions & 1 deletion apps/memos-local-plugin/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void {
ownerProfileId,
includeAllNamespaces: true,
});
const episodeIds = await deps.core.listEpisodes({ sessionId, limit, offset });
const episodeIds = await deps.core.listEpisodes({
sessionId,
limit,
offset,
includeAllNamespaces: true,
});
return {
episodeIds,
limit,
Expand Down
6 changes: 6 additions & 0 deletions apps/memos-local-plugin/server/routes/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void {
const groupByTurn = params.get("groupByTurn") === "true";
const includeTotal = params.get("includeTotal") !== "false";
const listLimit = includeTotal ? limit : limit + 1;
// Viewer list: show every namespace's rows by default (explicit
// ownerAgentKind / ownerProfileId query filters still narrow) —
// see overview.ts for why viewer reads must not follow the
// turn-scoped active namespace.
const rawTraces = await deps.core.listTraces({
limit: listLimit,
offset,
Expand All @@ -52,6 +56,7 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void {
ownerProfileId,
q,
groupByTurn,
includeAllNamespaces: true,
});
const { traces, hasMore } = trimTracePage(rawTraces, limit, groupByTurn);
const total = includeTotal
Expand All @@ -61,6 +66,7 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void {
ownerProfileId,
q,
groupByTurn,
includeAllNamespaces: true,
})
: undefined;
// When grouping, `traces.length === limit` is no longer a reliable
Expand Down
125 changes: 125 additions & 0 deletions apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,131 @@ describe("MemoryCore façade", () => {
expect(await core.listTraces({ limit: 10, groupByTurn: true })).toHaveLength(1);
});

// Regression for #2131: viewer dashboard counts must not "drift to
// zero" when a turn/session from a different sub-agent profile flips
// the core's active namespace. The viewer is a local single-user
// admin surface, so its aggregate reads pass `includeAllNamespaces`
// (same convention as diag.ts / session.ts routes) and must stay
// stable regardless of which namespace processed the last turn.
it("keeps metrics + listEpisodes stable across a namespace flip (includeAllNamespaces)", async () => {
pipeline = createPipeline(buildDeps(db!));
core = createMemoryCore(
pipeline,
resolveHome("openclaw", "/tmp/memos-mc-test"),
"test",
);
await core.init();

const mainNs = { agentKind: "openclaw", profileId: "main" };
const subagentNs = { agentKind: "openclaw", profileId: "subagent-x" };

// 1. A turn under the boot namespace writes one memory.
const start = await core.onTurnStart({
agent: "openclaw",
namespace: mainNs,
sessionId: "s-main",
userText: "remember the deploy checklist",
ts: 1_700_000_000_001,
});
await core.onTurnEnd({
agent: "openclaw",
namespace: mainNs,
sessionId: "s-main",
episodeId: start.query.episodeId!,
agentText: "stored the deploy checklist",
toolCalls: [],
ts: 1_700_000_000_002,
});

const before = await core.metrics({ days: 1, includeAllNamespaces: true });
expect(before.total).toBe(1);
const episodesBefore = await core.listEpisodes({
limit: 10,
includeAllNamespaces: true,
});
expect(episodesBefore).toHaveLength(1);

// 2. A session under a DIFFERENT profile flips activeNamespace
// (this is what the gateway/sub-agents do in production).
await core.openSession({
agent: "openclaw",
sessionId: "s-sub",
namespace: subagentNs,
});

// Namespace-scoped reads hide the other profile's row (intended
// multi-profile isolation)…
const scoped = await core.metrics({ days: 1 });
expect(scoped.total).toBe(0);
await expect(core.listEpisodes({ limit: 10 })).resolves.toHaveLength(0);

// …but the viewer's all-namespace reads must NOT drift.
const after = await core.metrics({ days: 1, includeAllNamespaces: true });
expect(after.total).toBe(1);
const episodesAfter = await core.listEpisodes({
limit: 10,
includeAllNamespaces: true,
});
expect(episodesAfter).toHaveLength(1);
});

// Namespace-scoped listEpisodes must apply `limit` AFTER the
// visibility filter. Paging at the repo level under-fills pages when
// rows from other namespaces occupy the fetched window, which reads
// as a false end-of-data to paginating callers.
it("fills scoped listEpisodes pages past other namespaces' rows", async () => {
pipeline = createPipeline(buildDeps(db!));
core = createMemoryCore(
pipeline,
resolveHome("openclaw", "/tmp/memos-mc-test"),
"test",
);
await core.init();

const mainNs = { agentKind: "openclaw", profileId: "main" };
const subNs = { agentKind: "openclaw", profileId: "subagent-x" };

const runTurn = async (
ns: typeof mainNs,
sessionId: string,
ts: number,
): Promise<void> => {
const start = await core!.onTurnStart({
agent: "openclaw",
namespace: ns,
sessionId,
userText: `note for ${sessionId}`,
ts,
});
await core!.onTurnEnd({
agent: "openclaw",
namespace: ns,
sessionId,
episodeId: start.query.episodeId!,
agentText: `stored for ${sessionId}`,
toolCalls: [],
ts: ts + 1,
});
};

// Three episodes, newest-first order: main-2, sub-1, main-1 — the
// sub-profile row sits inside the first page window of size 2.
await runTurn(mainNs, "s-main-1", 1_700_000_000_010);
await runTurn(subNs, "s-sub-1", 1_700_000_000_020);
await runTurn(mainNs, "s-main-2", 1_700_000_000_030);

// Active namespace is `main` after the last turn. A scoped page of
// 2 must contain BOTH main episodes, skipping the interleaved
// sub-profile row instead of consuming a page slot on it.
const page = await core.listEpisodes({ limit: 2 });
expect(page).toHaveLength(2);

// And the all-namespace read still sees everything.
await expect(
core.listEpisodes({ limit: 10, includeAllNamespaces: true }),
).resolves.toHaveLength(3);
});

it("records visible subagent task and result in the parent episode", async () => {
pipeline = createPipeline(buildDeps(db!));
core = createMemoryCore(
Expand Down
Loading
Loading