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
66 changes: 51 additions & 15 deletions apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2630,22 +2630,54 @@ export function createMemoryCore(
const needle = (input?.q ?? "").trim().toLowerCase();
const visible = (r: TraceRow) => visibleToCurrent(r);
if (!needle) {
const rows = handle.repos.traces.list({ sessionId: input?.sessionId, limit: 100_000 }).filter(visible);
if (!input?.groupByTurn) return rows.length;
const turnKeys = new Set<string>();
for (const r of rows) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`);
return turnKeys.size;
// Use a dedicated SELECT COUNT(*) instead of `list().length`.
// The previous implementation called `repos.traces.list({ limit: 100_000 })`
// and counted the returned rows, but `buildPageClauses` silently clamps
// every list `limit` to 500. That capped the Memories total at 500
// even when the database held 1400+ traces (#1593). The repo's
// `count` / `countTurns` issue real COUNT queries with no page-size
// cap, so they return the actual total.
//
// Pass the active namespace so the COUNT applies the SAME visibility
// predicate that `list` does — otherwise the total can include rows
// owned by other profiles/namespaces and break pagination math
// (#1674 review).
return input?.groupByTurn
? handle.repos.traces.countTurns({
sessionId: input?.sessionId,
namespace: activeNamespace,
})
: handle.repos.traces.count({
sessionId: input?.sessionId,
namespace: activeNamespace,
});
}
// q substring scan — mirror `listTraces`. Walk all matching
// traces from the repo (no limit) and apply the same filter.
const rows = handle.repos.traces.list({ sessionId: input?.sessionId }).filter(visible);
const matched = rows.filter((r) => {
return traceSearchHaystack(r).includes(needle);
});
if (!input?.groupByTurn) return matched.length;
const turnKeys = new Set<string>();
for (const r of matched) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`);
return turnKeys.size;
// traces from the repo and apply the same filter. We page through
// explicitly because the underlying `list` clamps each call at
// 500 rows, which would otherwise silently truncate the count.
const matchedTurnKeys = new Set<string>();
let matchedCount = 0;
const PAGE = 500;
for (let offset = 0; ; offset += PAGE) {
const page = handle.repos.traces.list({
sessionId: input?.sessionId,
namespace: activeNamespace,
limit: PAGE,
offset,
});
if (page.length === 0) break;
for (const r of page) {
if (!visible(r)) continue;
if (!traceSearchHaystack(r).includes(needle)) continue;
matchedCount++;
if (input?.groupByTurn) {
matchedTurnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`);
}
}
if (page.length < PAGE) break;
}
return input?.groupByTurn ? matchedTurnKeys.size : matchedCount;
}

async function listTraces(input?: {
Expand Down Expand Up @@ -3025,7 +3057,11 @@ export function createMemoryCore(
// the Overview "memories" metric matches what the Memories page
// shows: 1 user turn = 1 memory (regardless of how many tool calls
// / sub-steps were captured for that turn).
const totalTurns = handle.repos.traces.countTurns();
//
// Pass the active namespace so the Overview metric stays consistent
// with what the current profile actually sees on the Memories page;
// otherwise it would aggregate turns owned by other profiles too.
const totalTurns = handle.repos.traces.countTurns({ namespace: activeNamespace });

return {
total: totalTurns,
Expand Down
12 changes: 11 additions & 1 deletion apps/memos-local-plugin/core/runtime/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,22 @@ export function visibilityWhere(
): VisibilityWhere {
const col = (name: string) => `${alias ? `${alias}.` : ""}${name}`;
const normalized = normalizeNamespace(ns, ns?.agentKind ?? "unknown");
// Mirrors `isVisibleTo` exactly so SQL list/count and in-memory filters
// agree. A row is visible iff:
// 1. it's owned by the current (agentKind, profileId), OR
// 2. it has been shared with scope local/public/hub, OR
// 3. it has no recorded owner (legacy seed rows: agent_kind = 'unknown'
// AND profile_id = 'default'). Without this branch, pushing the
// predicate into SQL would silently drop pre-namespace rows that
// `isVisibleTo` would have surfaced.
return {
sql:
`((` +
`${col("owner_agent_kind")} = @vis_owner_agent_kind AND ` +
`${col("owner_profile_id")} = @vis_owner_profile_id` +
`) OR COALESCE(${col("share_scope")}, 'private') IN ('local', 'public', 'hub'))`,
`) OR COALESCE(${col("share_scope")}, 'private') IN ('local', 'public', 'hub')` +
` OR (COALESCE(${col("owner_agent_kind")}, 'unknown') = 'unknown' AND ` +
`COALESCE(${col("owner_profile_id")}, 'default') = 'default'))`,
params: {
vis_owner_agent_kind: normalized.agentKind,
vis_owner_profile_id: normalized.profileId,
Expand Down
33 changes: 29 additions & 4 deletions apps/memos-local-plugin/core/storage/repos/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
timeRangeWhere,
toBlob,
toJsonText,
visibilityWhere,
} from "./_helpers.js";

const COLUMNS = [
Expand Down Expand Up @@ -59,6 +60,22 @@ export type TraceSearchMeta = {
};

export function makeTracesRepo(db: StorageDb) {
/**
* Shared visibility predicate for `list`/`count`/`countTurns`/`listTurnKeys`.
*
* Keeping the WHERE fragment in one place is the whole point: the count
* queries MUST stay in lock-step with what `list` would return, otherwise
* pagination math goes wrong and totals can leak the existence of rows
* owned by other namespaces (#1674 review on #1593).
*/
function buildVisibilityClause(
filter: { namespace?: TraceListFilter["namespace"] },
): { sql: string | null; params: Record<string, unknown> } {
if (!filter.namespace) return { sql: null, params: {} };
const v = visibilityWhere(filter.namespace);
return { sql: v.sql, params: v.params };
}

const insert = db.prepare(buildInsert({ table: "traces", columns: COLUMNS }));
const upsert = db.prepare(
buildInsert({ table: "traces", columns: COLUMNS, onConflict: "replace" }),
Expand Down Expand Up @@ -111,8 +128,9 @@ export function makeTracesRepo(db: StorageDb) {

list(filter: TraceListFilter = {}): TraceRow[] {
const tr = timeRangeWhere(filter, "ts");
const vis = buildVisibilityClause(filter);
const fragments: string[] = [];
const params: Record<string, unknown> = { ...tr.params };
const params: Record<string, unknown> = { ...tr.params, ...vis.params };
if (filter.sessionId) {
fragments.push(`session_id = @session_id`);
params.session_id = filter.sessionId;
Expand All @@ -126,6 +144,7 @@ export function makeTracesRepo(db: StorageDb) {
params.min_abs_value = filter.minAbsValue;
}
if (tr.sql) fragments.push(tr.sql);
if (vis.sql) fragments.push(vis.sql);
const where = joinWhere(fragments);
const page = buildPageClauses(filter, "ts");
const sql = `SELECT ${COLUMNS.join(", ")} FROM traces ${where} ${page}`;
Expand All @@ -138,8 +157,9 @@ export function makeTracesRepo(db: StorageDb) {
*/
count(filter: Omit<TraceListFilter, "limit" | "offset"> = {}): number {
const tr = timeRangeWhere(filter, "ts");
const vis = buildVisibilityClause(filter);
const fragments: string[] = [];
const params: Record<string, unknown> = { ...tr.params };
const params: Record<string, unknown> = { ...tr.params, ...vis.params };
if (filter.sessionId) {
fragments.push(`session_id = @session_id`);
params.session_id = filter.sessionId;
Expand All @@ -153,6 +173,7 @@ export function makeTracesRepo(db: StorageDb) {
params.min_abs_value = filter.minAbsValue;
}
if (tr.sql) fragments.push(tr.sql);
if (vis.sql) fragments.push(vis.sql);
const where = joinWhere(fragments);
const sql = `SELECT COUNT(*) AS n FROM traces ${where}`;
const row = db.prepare<typeof params, { n: number }>(sql).get(params);
Expand All @@ -165,8 +186,9 @@ export function makeTracesRepo(db: StorageDb) {
* counted as 1. Used by the Memories viewer for accurate pagination.
*/
countTurns(filter: Omit<TraceListFilter, "limit" | "offset"> = {}): number {
const vis = buildVisibilityClause(filter);
const fragments: string[] = [];
const params: Record<string, unknown> = {};
const params: Record<string, unknown> = { ...vis.params };
if (filter.sessionId) {
fragments.push(`session_id = @session_id`);
params.session_id = filter.sessionId;
Expand All @@ -175,6 +197,7 @@ export function makeTracesRepo(db: StorageDb) {
fragments.push(`episode_id = @episode_id`);
params.episode_id = filter.episodeId;
}
if (vis.sql) fragments.push(vis.sql);
const where = joinWhere(fragments);
const sql = `SELECT COUNT(*) AS n FROM (SELECT DISTINCT episode_id, turn_id FROM traces ${where})`;
const row = db.prepare<typeof params, { n: number }>(sql).get(params);
Expand All @@ -187,8 +210,9 @@ export function makeTracesRepo(db: StorageDb) {
* fetch a page of "memories" (1 turn = 1 memory).
*/
listTurnKeys(filter: TraceListFilter = {}): Array<{ episodeId: string | null; turnId: number; maxTs: number }> {
const vis = buildVisibilityClause(filter);
const fragments: string[] = [];
const params: Record<string, unknown> = {};
const params: Record<string, unknown> = { ...vis.params };
if (filter.sessionId) {
fragments.push(`session_id = @session_id`);
params.session_id = filter.sessionId;
Expand All @@ -197,6 +221,7 @@ export function makeTracesRepo(db: StorageDb) {
fragments.push(`episode_id = @episode_id`);
params.episode_id = filter.episodeId;
}
if (vis.sql) fragments.push(vis.sql);
const where = joinWhere(fragments);
const limit = Math.max(1, Math.min(500, filter.limit ?? 50));
const offset = Math.max(0, filter.offset ?? 0);
Expand Down
9 changes: 9 additions & 0 deletions apps/memos-local-plugin/core/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type BetterSqlite3 from "better-sqlite3";

import type { RuntimeNamespace } from "../../agent-contract/dto.js";
import type { EpochMs, EpisodeId, TraceId } from "../types.js";

// ─── Database handle ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -91,6 +92,14 @@ export interface TraceListFilter extends PageOptions, TimeRange {
/** Only traces with |value| >= this (absolute). */
minAbsValue?: number;
traceIds?: TraceId[];
/**
* If provided, restrict results to rows visible to this namespace —
* either owned by the namespace's (agentKind, profileId) or shared with
* scope `local`/`public`/`hub`. Mirrors the in-memory `isVisibleTo`
* predicate but pushes it into SQL so `count`/`countTurns` agree with
* `list`.
*/
namespace?: RuntimeNamespace;
}

export interface PolicyListFilter extends PageOptions, TimeRange {
Expand Down
Loading