Skip to content
Merged
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
39 changes: 39 additions & 0 deletions docs/people-map-whitelist-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# people_map — next fix design note (not yet implemented)

## Problem
`extractPeopleMentions` in `lib/services/memory-distillation-service.ts` treats any capitalized
token-sequence as a person, then filters with a hand-maintained stoplist
(`PERSON_NAME_STOPWORDS`). PRs #123/#124 added dedup, alias canonicalization, caps, and a growing
stoplist — but a stoplist can never enumerate every capitalized non-name word. Live `people_map`
still surfaces junk like `Money`, `Strong`, `Clean`, `Boracay`, `Codex`, `Build`, `Googlebot`,
`Uploaded Janine Tan`, `Ong Messenger`.

**This is whack-a-mole. Do not add more stoplist words.**

## Decision
The correct next step is a **known-people whitelist (Option A)** — deterministic, no model calls,
no embeddings, no gated intelligence. NER (Option B) is deferred because it implies model behavior
(`PANDORA_ENABLE_MODEL_CALLS`, gated).

## Proposed shape (for a future PR)
1. **Static config `config/known-people.json`** (per-namespace), e.g.:
```json
{ "au": { "canonical": ["Janine Tan", "Mang Jun", "Joven Del Rosario"], "aliases": { "Janine": "Janine Tan", "Jana": "Jana", "Jan Jan": "Janine Tan" } },
"real_life": { "canonical": ["Joven", "Patty"], "aliases": {} } }
```
2. `extractPeopleMentions` becomes whitelist-first:
- a matched capitalized span is a **person** only if it (or an alias) is in the namespace's
`canonical`/`aliases` set;
- everything else goes to a new **`candidate_entities`** array (name + event_ids), *not*
`people_map`, so unknown capitalized nouns are surfaced for review but never asserted as people;
- keep existing dedup, alias canonicalization, and caps.
3. Optionally seed the whitelist from confirmed `memory_profiles` of type `person_profile`
(subject_key = confirmed person) so the whitelist grows only from reviewed data.
4. Tests: whitelisted names appear in `people_map`; unknown nouns go to `candidate_entities`;
aliases canonicalize; AU/real_life whitelists stay separate.

## Why not implement now
There is no existing static known-people config to reuse, and inventing the whitelist contents is a
data/product decision (who counts as a tracked person, especially given AU fictionalization rules and
real-person privacy boundaries). Blocked on that decision; documented here so the next session can
pick it up without re-deriving it. Future NER remains gated.
17 changes: 16 additions & 1 deletion lib/services/memory-bridge-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ export async function createContextPack(client: MemoryBridgeDbClient, pack: Omit
if (!pf.ok) return pf;
const result = await client.from<MemoryContextPack>("memory_context_packs").insert({ ...pack, user_id: pf.data.userId, namespace: pf.data.namespace, status: pack.status ?? "active" }).select("*").single();
if (result.error || !result.data) return { ok: false, blockers: ["context_pack_write_failed"], warnings: [result.error?.message ?? "unknown context pack write failure"], next_step: "Check memory_context_packs schema and RLS." };
// Supersede-on-distill: keep exactly one active pack per (user, namespace, pack_type). Status-only
// and reversible — archives older active packs, never deletes rows, never touches memory_events,
// never crosses namespace or pack_type. Best-effort: a failure here only adds a warning.
const warnings: string[] = [];
if (result.data.status === "active") {
const superseded = await (client.from("memory_context_packs")
.update({ status: "archived", updated_at: new Date().toISOString() })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Audit archived context packs when superseding

If this update archives any prior active packs, it mutates persisted memory_context_packs rows without an audit entry for those archived record ids; the only audit write below records the newly inserted pack's id. That leaves the supersede status changes outside the repo's audit-backed memory-write trail and makes admin audit/readback unable to prove which packs were retired. Please append audit rows for the affected ids, ideally in the same transactional boundary as the archive update.

Useful? React with 👍 / 👎.

.eq("user_id", pf.data.userId)
.eq("namespace", pf.data.namespace)
.eq("pack_type", result.data.pack_type)
.eq("status", "active")
.neq("id", result.data.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize supersede updates for concurrent distills

When two distills for the same user/namespace/pack_type run concurrently, each insert returns an active row and then this update archives every other active row, including the other request's newly inserted pack. An interleaving like A insert, B insert, A archives B, then B archives A leaves no active pack while both callers returned an active result. Please make the supersede operation transactional/serialized or restrict it to rows older than the inserted pack so concurrent distills cannot retire each other.

Useful? React with 👍 / 👎.

.select("id") as unknown as Promise<{ data: unknown[] | null; error: { message: string } | null }>);
if (superseded.error) warnings.push(`supersede_prior_packs_failed: ${superseded.error.message}`);
}
await audit(client, { userId: pf.data.userId, namespace: pf.data.namespace, action: "memory_context_pack_distilled", table: "memory_context_packs", recordId: result.data.id, metadata: { packId: result.data.id, packType: result.data.pack_type } });
return { ok: true, data: result.data, blockers: [], warnings: [] };
return { ok: true, data: result.data, blockers: [], warnings };
}
194 changes: 194 additions & 0 deletions tests/unit/retrieval-eval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it } from "vitest";
import { getMemoryContextTool, getLatestContextPackTool } from "@/lib/services/pandora-mcp-tools";
import { buildAdaptiveChatGptContext } from "@/lib/services/adaptive-chatgpt-context-service";
import { createContextPack } from "@/lib/services/memory-bridge-service";
import { runMcpAction } from "@/lib/services/pandora-mcp-action-envelope";

// A small namespace-aware in-memory client: reads filter the store by the recorded eq/neq
// predicates, inserts assign an id, updates patch matching rows (and are recorded in `ops`).
type Row = Record<string, any>;
function makeClient(store: Record<string, Row[]>, ops: any[] = []) {
let counter = 0;
return {
from(table: string) {
const eqs: Record<string, any> = {};
const neqs: Record<string, any> = {};
let mode: "select" | "insert" | "update" = "select";
let inserted: Row[] = [];
let patch: Row | null = null;
let limitN: number | undefined;
const src = () => (store[table] ??= []);
const matches = (row: Row) =>
Object.entries(eqs).every(([k, v]) => row[k] === v) && Object.entries(neqs).every(([k, v]) => row[k] !== v);
const rows = () => {
const filtered = src().filter(matches);
return limitN != null ? filtered.slice(0, limitN) : filtered;
};
const builder: any = {
select() { return builder; },
insert(value: Row | Row[]) { mode = "insert"; inserted = (Array.isArray(value) ? value : [value]).map((r) => ({ id: r.id ?? `gen-${++counter}`, ...r })); return builder; },
update(value: Row) { mode = "update"; patch = value; return builder; },
eq(col: string, val: any) { eqs[col] = val; return builder; },
neq(col: string, val: any) { neqs[col] = val; return builder; },
order() { return builder; },
limit(n: number) { limitN = n; return builder; },
single() {
if (mode === "insert") { src().push(...inserted); return Promise.resolve({ data: inserted[0] ?? null, error: null }); }
return Promise.resolve({ data: rows()[0] ?? null, error: null });
},
then(resolve: any, reject: any) {
let data: any;
if (mode === "insert") { src().push(...inserted); data = inserted; }
else if (mode === "update") {
const affected = src().filter(matches);
for (const row of affected) Object.assign(row, patch);
ops.push({ table, patch, eqs: { ...eqs }, neqs: { ...neqs }, affected: affected.map((r) => r.id) });
data = affected;
} else data = rows();
return Promise.resolve({ data, error: null }).then(resolve, reject);
},
};
return builder;
},
} as any;
}

const USER = "u-64110799";
const mcpPrincipal = { ok: true as const, authType: "mcp_bearer_token" as const, userId: USER };

function pack(namespace: string, extra: Row = {}): Row {
return { id: `pack-${namespace}`, user_id: USER, namespace, pack_type: "master", status: "active", title: "Pandora master context pack", summary: "", key_points: [], active_projects: [], people_map: [], decisions: [], risks: [], open_loops: [], generated_from_event_ids: [], created_at: "2026-07-03T00:00:00Z", ...extra };
}
function evt(namespace: string, id: string, raw_text: string): Row {
return { id, user_id: USER, namespace, source: "chatgpt_user_direct", raw_text, extracted_summary: raw_text, status: "captured", importance: 8, sensitivity: "low", created_at: "2026-07-03T00:00:00Z" };
}

function seededStore(): Record<string, Row[]> {
return {
memory_context_packs: [
pack("au", { summary: "AU FICTIONALIZATION RULE — Janine Tan / Mang Jun canon", key_points: [{ point: "AU CONSENT / FICTIONALIZATION RULE — Janine Tan", event_id: "au1", source: "chatgpt_user_direct", importance: 10 }] }),
pack("real_life", { summary: "PANDORA MEMORY ROADMAP + PLP Pueblo La Perla Boracay", key_points: [{ point: "PANDORA MEMORY ROADMAP — user asked to save", event_id: "rl1", source: "chatgpt_user_direct", importance: 9 }] }),
],
memory_events: [
evt("au", "au1", "AU FICTIONALIZATION RULE — Janine Tan is a fictionalized character; Mang Jun archetype."),
evt("real_life", "rl1", "PANDORA MEMORY ROADMAP — user asked to save. PLP Pueblo La Perla Boracay resort project status."),
],
memory_profiles: [],
memory_open_loops: [],
};
}

describe("retrieval eval (#11)", () => {
it("AU query returns AU context and excludes real_life/PLP content", async () => {
const result = await getMemoryContextTool(makeClient(seededStore()), mcpPrincipal, { namespace: "au" });
const text = JSON.stringify(result);
expect(result.namespace).toBe("au");
expect(text).toContain("Janine");
expect(text).toContain("FICTIONALIZATION"); // AU canon retrievable from AU
expect(text).not.toMatch(/PLP|Pueblo|ROADMAP/);
});

it("real_life query returns Pandora/PLP context and excludes AU story content", async () => {
const result = await getMemoryContextTool(makeClient(seededStore()), mcpPrincipal, { namespace: "real_life" });
const text = JSON.stringify(result);
expect(result.namespace).toBe("real_life");
expect(text).toContain("ROADMAP"); // roadmap retrievable from real_life
expect(text).toMatch(/PLP|Pueblo/);
expect(text).not.toContain("Janine");
});

it("routing through the MCP boundary adds controlled envelope fields", async () => {
const client = makeClient(seededStore());
const content = await runMcpAction(() => getMemoryContextTool(client, mcpPrincipal, { namespace: "au" }));
const body = JSON.parse(content.content[0].text); // must be valid JSON
expect(body.ok).toBe(true);
expect(body.request_id).toMatch(/^req_/);
expect(body.fallback_used).toBe(false);
expect(body.namespace).toBe("au");
});

it("falls back to latest context pack (fallback_used=true) when the primary read fails", async () => {
const client = makeClient(seededStore());
const content = await runMcpAction(
() => Promise.reject(new Error("context_read_failed: primary down")),
{ fallback: () => getLatestContextPackTool(client, mcpPrincipal, { namespace: "au" }) },
);
const body = JSON.parse(content.content[0].text);
expect(body.ok).toBe(true);
expect(body.fallback_used).toBe(true);
expect(body.warnings).toContain("primary_failed:context_read_failed");
expect(JSON.stringify(body)).not.toContain("Pueblo"); // still AU-scoped
});

it("caps the context payload (no giant/stream-breaking response)", async () => {
const store = seededStore();
store.memory_context_packs[0].people_map = [{ name: "Janine Tan", event_ids: Array.from({ length: 800 }, (_, i) => `au-evt-${i}`), notes: ["n"] }];
const result: any = await getMemoryContextTool(makeClient(store), mcpPrincipal, { namespace: "au" });
expect(JSON.stringify(result.context_pack).length).toBeLessThanOrEqual(12000);
});

it("adaptive context surfaces the refreshed operating profile (summary/confidence/preferences)", async () => {
const store = seededStore();
store.memory_profiles = [{
id: "prof-au", user_id: USER, namespace: "au", profile_type: "operating_profile", subject_key: "global", status: "active",
summary: "Adaptive au operating profile from 11 memories: 6 preferences", confidence: 0.88,
preferences: [{ text: "AU FICTIONALIZATION RULE — use a fictional alias", event_id: "au1" }], facts: [], decisions: [],
}];
const ctx: any = await buildAdaptiveChatGptContext(makeClient(store), { user_id: USER, namespace: "au" });
expect(ctx.adaptive_profile_summary).toContain("Adaptive au operating profile");
expect(ctx.adaptive_profile_confidence).toBe(0.88);
expect(ctx.writing_rules.join(" ")).toContain("FICTIONALIZATION");
});
});

describe("supersede-on-distill", () => {
const bridgePrincipal = { ok: true as const, userId: USER, createdBy: USER, authType: "bridge_token" as const, operator: true };
const runtime = { config: { memoryCaptureApiEnabled: false, memoryContextApiEnabled: true, memoryDistillationEnabled: true }, gates: { memoryCaptureApiEnabled: { envVar: "PANDORA_ENABLE_MCP_CAPTURE" }, memoryContextApiEnabled: { envVar: "PANDORA_ENABLE_MCP" }, memoryDistillationEnabled: { envVar: "PANDORA_ENABLE_MCP_DISTILLATION" } } } as any;
const newPack = (namespace: string) => ({ namespace, user_id: USER, pack_type: "master" as const, title: "t", summary: "s", key_points: [], active_projects: [], people_map: [], decisions: [], risks: [], open_loops: [], generated_from_event_ids: [] });

it("archives older active master packs for the same namespace but keeps the newest active", async () => {
const store: Record<string, Row[]> = {
memory_context_packs: [
{ id: "au-old", user_id: USER, namespace: "au", pack_type: "master", status: "active" },
{ id: "au-daily", user_id: USER, namespace: "au", pack_type: "daily", status: "active" },
{ id: "rl-old", user_id: USER, namespace: "real_life", pack_type: "master", status: "active" },
],
audit_logs: [],
};
const ops: any[] = [];
const result: any = await createContextPack(makeClient(store, ops), newPack("au") as any, bridgePrincipal, runtime);
expect(result.ok).toBe(true);

const auMasters = store.memory_context_packs.filter((p) => p.namespace === "au" && p.pack_type === "master");
const activeAuMasters = auMasters.filter((p) => p.status === "active");
expect(activeAuMasters).toHaveLength(1); // exactly one active au master (the new one)
expect(activeAuMasters[0].id).toBe(result.data.id);
expect(store.memory_context_packs.find((p) => p.id === "au-old")!.status).toBe("archived");

// did NOT touch other pack types or the other namespace
expect(store.memory_context_packs.find((p) => p.id === "au-daily")!.status).toBe("active");
expect(store.memory_context_packs.find((p) => p.id === "rl-old")!.status).toBe("active");

// status-only archive, never a delete
expect(store.memory_context_packs.find((p) => p.id === "au-old")).toBeDefined();
const supersedeOp = ops.find((o) => o.table === "memory_context_packs" && o.patch?.status === "archived");
expect(supersedeOp).toBeDefined();
expect(supersedeOp.eqs.status).toBe("active");
expect(supersedeOp.eqs.pack_type).toBe("master");
expect(supersedeOp.neqs.id).toBe(result.data.id);
});

it("distilling real_life does not affect AU packs", async () => {
const store: Record<string, Row[]> = {
memory_context_packs: [
{ id: "au-keep", user_id: USER, namespace: "au", pack_type: "master", status: "active" },
{ id: "rl-old", user_id: USER, namespace: "real_life", pack_type: "master", status: "active" },
],
audit_logs: [],
};
await createContextPack(makeClient(store), newPack("real_life") as any, bridgePrincipal, runtime);
expect(store.memory_context_packs.find((p) => p.id === "au-keep")!.status).toBe("active");
expect(store.memory_context_packs.find((p) => p.id === "rl-old")!.status).toBe("archived");
});
});
Loading