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
102 changes: 93 additions & 9 deletions lib/services/memory-distillation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,77 @@ export function extractRisks(events: MemoryEvent[]) {
return topEvents(events.filter((event) => includesAny(event.raw_text, riskWords)), 10).map((event) => ({ event_id: event.id, text: asSentence(event.raw_text), source: event.source, sensitivity: event.sensitivity ?? "medium" }));
}

export function extractPeopleMentions(events: MemoryEvent[]) {
const people = new Map<string, { name: string; event_ids: string[]; notes: string[] }>();
// Roadmap Sprint 1 (#1 — stabilize output): stoplist junk entities, dedupe event ids, merge
// single-token aliases into their full name, and cap sizes so people_map stays sharp instead
// of dumping every capitalized sentence-opener as a "person".
const MAX_PEOPLE = 12;
const MAX_EVENT_IDS_PER_PERSON = 8;

// Capitalized words that are pronouns / imperatives / sentence-openers / domain labels and
// must never be treated as a person's name.
const PERSON_NAME_STOPWORDS = new Set<string>([
"the", "a", "an", "this", "that", "these", "those", "it", "its",
"he", "him", "his", "she", "her", "hers", "they", "them", "their", "we", "us", "our", "ours", "you", "your", "yours", "i", "me", "my", "mine",
"do", "don", "dont", "does", "did", "doing", "done", "use", "using", "used", "add", "set", "make", "made", "ask", "treat", "note", "source",
"keep", "avoid", "preserve", "allow", "allowed", "required", "reinforced", "working", "current", "best", "direction", "essential", "future", "important",
"if", "when", "then", "else", "and", "but", "or", "nor", "for", "so", "yet", "not", "no", "yes", "never", "always", "only", "also",
"before", "during", "after", "every", "each", "all", "any", "some", "main", "core", "both", "new", "old",
"rule", "rules", "story", "scene", "scenes", "style", "canon", "user", "users", "taglish", "status",
"pandora", "chatgpt", "memory",
]);

type PersonEntry = { name: string; event_ids: string[]; notes: string[] };

function isLikelyPersonName(name: string): boolean {
const tokens = name.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return false;
if (PERSON_NAME_STOPWORDS.has(tokens[0].toLowerCase())) return false;
if (tokens.length === 1) return tokens[0].length >= 3;
return true;
}

// Merge a single-token name (e.g. "Janine") into a multi-token name that starts with it
// (e.g. "Janine Tan"). Distinct aliases with no shared full name (e.g. "Jana") stay separate.
function canonicalizePeople(people: Map<string, PersonEntry>): PersonEntry[] {
const entries = [...people.values()];
const multi = entries.filter((entry) => entry.name.includes(" "));
const kept: PersonEntry[] = [];
for (const entry of entries) {
if (!entry.name.includes(" ")) {
const target = multi.find((m) => m.name.split(/\s+/)[0].toLowerCase() === entry.name.toLowerCase());
if (target && target !== entry) {
for (const id of entry.event_ids) if (!target.event_ids.includes(id)) target.event_ids.push(id);
for (const note of entry.notes) if (target.notes.length < 2 && !target.notes.includes(note)) target.notes.push(note);
continue;
}
}
kept.push(entry);
}
return kept;
}

export function extractPeopleMentions(events: MemoryEvent[], opts: { maxPeople?: number; maxEventIdsPerPerson?: number } = {}) {
const maxPeople = opts.maxPeople ?? MAX_PEOPLE;
const maxIds = opts.maxEventIdsPerPerson ?? MAX_EVENT_IDS_PER_PERSON;
const people = new Map<string, PersonEntry>();
for (const event of events) {
for (const match of event.raw_text.matchAll(/\b[A-Z][a-z]+(?:\s[A-Z][a-z]+)?\b/g)) {
const name = match[0];
if (["Pandora", "ChatGPT", "Memory"].includes(name)) continue;
// One event contributes each distinct name at most once (no per-occurrence id duplication).
const namesInEvent = new Set<string>();
for (const match of event.raw_text.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b/g)) {
const name = match[0].trim();
if (isLikelyPersonName(name)) namesInEvent.add(name);
}
for (const name of namesInEvent) {
const entry = people.get(name) ?? { name, event_ids: [], notes: [] };
entry.event_ids.push(event.id);
if (!entry.event_ids.includes(event.id)) entry.event_ids.push(event.id);
if (entry.notes.length < 2) entry.notes.push(asSentence(event.raw_text));
people.set(name, entry);
}
}
return [...people.values()].sort((a, b) => b.event_ids.length - a.event_ids.length).slice(0, 12);
return canonicalizePeople(people)
.sort((a, b) => b.event_ids.length - a.event_ids.length || a.name.localeCompare(b.name))
.slice(0, maxPeople)
.map((entry) => ({ name: entry.name, event_ids: entry.event_ids.slice(0, maxIds), notes: entry.notes }));
}

export function extractProjectMentions(events: MemoryEvent[]) {
Expand Down Expand Up @@ -91,8 +149,31 @@ export function buildMasterContextPack(namespace: MemoryBridgeNamespace, userId:
};
}

export function compactContextResponse(pack: MemoryContextPack | null, events: MemoryEvent[], input: { include_risks?: boolean; include_people?: boolean; include_projects?: boolean }) {
return {
const DEFAULT_MAX_PAYLOAD_CHARS = 12000;
function payloadChars(value: unknown): number { return JSON.stringify(value).length; }

// Progressive, deterministic slimming so a context response never dumps a giant payload.
// Trims the heaviest fields first (people event ids/notes), then list lengths, then the summary.
function slimContextResponse(response: Record<string, unknown>, maxChars: number): Record<string, unknown> {
const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
if (payloadChars(response) <= maxChars) return response;
response.people_map = list(response.people_map).map((person) => {
const p = (person ?? {}) as Record<string, unknown>;
return { ...p, event_ids: list(p.event_ids).slice(0, 3), notes: list(p.notes).slice(0, 1) };
});
if (payloadChars(response) <= maxChars) return response;
response.key_points = list(response.key_points).slice(0, 6);
response.open_loops = list(response.open_loops).slice(0, 6);
response.risks = list(response.risks).slice(0, 6);
response.active_projects = list(response.active_projects).slice(0, 6);
Comment on lines +165 to +168

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 Include decisions in payload slimming

When an active context pack is decision-heavy (or has older persisted decisions entries with large text), the new max_payload_chars cap can still be exceeded because slimming truncates key_points, open_loops, risks, and active_projects but leaves decisions unbounded. In that scenario get_memory_context can continue returning oversized payloads despite the new budget, so decisions needs to be capped/trimmed as part of the same slimming pass or the function should re-check and keep trimming until it is actually under budget.

Useful? React with 👍 / 👎.

if (payloadChars(response) <= maxChars) return response;
response.people_map = list(response.people_map).slice(0, 6);
response.summary = String(response.summary ?? "").slice(0, 1200);
return response;
}

export function compactContextResponse(pack: MemoryContextPack | null, events: MemoryEvent[], input: { include_risks?: boolean; include_people?: boolean; include_projects?: boolean; max_payload_chars?: number; debug?: boolean }) {
const response = {
title: pack?.title ?? "Pandora context pack unavailable",
summary: pack?.summary ?? summarizeEventsDeterministically(events),
key_points: pack?.key_points ?? keyPoints(events),
Expand All @@ -107,4 +188,7 @@ export function compactContextResponse(pack: MemoryContextPack | null, events: M
"Ask before storing new long-term memories.",
],
};
// debug mode returns the full payload; default responses are capped to stay compact.
if (input.debug) return response;
return slimContextResponse(response, Math.max(2000, Number(input.max_payload_chars ?? DEFAULT_MAX_PAYLOAD_CHARS)));
}
2 changes: 1 addition & 1 deletion lib/services/pandora-mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { disabledPostAnswer, runPostAnswerTurn, runPreAnswerTurn } from "@/lib/s

const namespaceSchema = z.enum(["real_life", "au"]);
export const latestContextPackInputSchema = z.object({ namespace: namespaceSchema, pack_type: z.enum(["daily", "master"]).optional() });
export const memoryContextInputSchema = z.object({ namespace: namespaceSchema, query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().max(100).optional(), include_risks: z.boolean().optional(), include_people: z.boolean().optional(), include_projects: z.boolean().optional() });
export const memoryContextInputSchema = z.object({ namespace: namespaceSchema, query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().max(100).optional(), include_risks: z.boolean().optional(), include_people: z.boolean().optional(), include_projects: z.boolean().optional(), max_payload_chars: z.number().int().positive().max(200000).optional(), debug: z.boolean().optional() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the server registration schema and whether args are passed through untouched.
rg -nP 'registerTool\(\s*"get_memory_context"' -A3 lib/services/pandora-mcp-server.ts
rg -nP 'max_payload_chars|debug' lib/services/pandora-mcp-server.ts

Repository: besfeng23/Memory

Length of output: 1775


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tool implementation and the surrounding validation flow.
ast-grep outline lib/services/pandora-mcp-tools.ts --view expanded
sed -n '1,260p' lib/services/pandora-mcp-tools.ts | cat -n

echo
echo '---'
echo

# Find the exact handler signature and any re-parsing of args.
rg -n "getMemoryContextTool|memoryContextInputSchema|parse\(|safeParse\(|args: unknown|inputSchema" lib/services/pandora-mcp-tools.ts lib/services/pandora-mcp-server.ts

Repository: besfeng23/Memory

Length of output: 47229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n '"`@modelcontextprotocol/sdk`"|mcp' package.json package-lock.json pnpm-lock.yaml yarn.lock

Repository: besfeng23/Memory

Length of output: 388


🌐 Web query:

@modelcontextprotocol/sdk Server.registerTool inputSchema unknown properties handler args stripped or preserved

💡 Result:

In the Model Context Protocol (MCP) TypeScript SDK, the handling of input arguments depends on how you define your inputSchema and whether the schema effectively validates the incoming data. 1. Schema Validation and Stripping: The inputSchema provided to registerTool is used to validate incoming JSON-RPC tool call arguments [1][2]. If a schema is defined but does not explicitly include specific properties, or if validation fails or strips them, those properties will not reach your tool handler [3]. The SDK is designed to ensure that the arguments passed to your handler strictly conform to the provided inputSchema [1][4]. 2. Standard Schema and Zod Support: The SDK (as of v2) natively supports any "Standard Schema" library (e.g., Zod) [1][4]. When you pass a proper z.object({...}) schema, the SDK uses it to parse and validate the arguments [1][2]. If your handler receives an empty object {} even when the client sends data, it often indicates that the schema provided was incorrectly constructed (e.g., passing a raw object shape instead of a wrapped Zod object in older versions) or that the validation logic stripped the unexpected fields [3]. 3. Compatibility for Raw Shapes: While earlier versions of the SDK experienced issues where raw Zod shapes were incorrectly handled or caused arguments to be stripped [3][5], recent versions include compatibility layers that auto-wrap raw Zod shapes into z.object({...}) to prevent this behavior [6][7][8]. Recommendation: To ensure all properties are preserved, always wrap your input definitions in z.object({...}) [1][7][2]. If you continue to see arguments being stripped, verify that your schema definition matches the structure of the incoming data exactly; the SDK will discard any properties not defined in the schema to ensure strict adherence to the protocol's type safety requirements [1][3].

Citations:


🌐 Web query:

site:github.com modelcontextprotocol sdk registerTool inputSchema args unknown properties

💡 Result:

In the Model Context Protocol (MCP) SDK, handling of "unknown" or additional properties within a tool's inputSchema is determined by the underlying JSON Schema validation settings. MCP specifications (via SEP-2106 and related initiatives) align tool schemas with JSON Schema 2020-12 [1][2]. Under this specification, tool inputs are not strictly prohibited from containing unknown properties by default, but the behavior depends on how you define your schema [3][4]: 1. Default Behavior: If you define your inputSchema simply as an object with properties, the JSON Schema default behavior allows for additional properties [3][4]. 2. Restricting Unknowns: To explicitly reject unknown properties, you should define your schema with "additionalProperties": false [3][5]. 3. TypeScript SDK Behavior: The MCP TypeScript SDK uses Zod for validation [6][7]. When using Zod schemas for inputSchema: - If you want validation to strictly reject unknown properties, use z.object({...}).strict() [8]. - If you want to preserve or allow extra properties, you can use .passthrough() [8]. - Be aware that in some SDK versions, if you do not explicitly set strictness, there may be nuances in how Zod translates to JSON Schema regarding the additionalProperties field [8]. If you are encountering issues where extra properties are being dropped or blocked, verify if your Zod schema is configured with .strict() (which blocks extras) or if the SDK version you are using is applying default JSON Schema behaviors that differ from your expectations [8]. As of recent updates, the SDK is designed to be more compliant with passing through full JSON Schema 2020-12 keywords [2][9].

Citations:


🌐 Web query:

"@modelcontextprotocol/sdk" registerTool inputSchema handler args validate preserve unknown keys

💡 Result:

In the MCP TypeScript SDK (v2), the registerTool method associates a tool with a name, metadata, and a handler [1]. The inputSchema parameter defines the argument structure and handles validation using the Standard Schema interface [2][3]. Key details regarding your query: 1. Input Schema and Validation: The registerTool method expects an inputSchema that conforms to the Standard Schema specification (e.g., Zod, ArkType, or Valibot) [2][3]. This schema is used by the SDK to advertise the tool's capabilities via tools/list and to validate incoming arguments during tools/call [3]. 2. Handling Unknown Keys: The behavior regarding unknown keys in the input depends on the underlying schema library configuration (e.g., Zod) [4]. By default, Zod objects often "strip" unknown keys [4]. To change this behavior: - To reject unknown keys: Use.strict on your Zod schema [4]. - To preserve unknown keys: Use.passthrough on your Zod schema [4]. 3. Handler Arguments: When an inputSchema is provided, the handler is invoked with (args, ctx) [5]. The args object contains the validated and processed parameters [5]. 4. Compatibility: While v2 natively requires a full Standard Schema object (e.g., z.object({...})), recent updates (as of April 2026) added compatibility to automatically wrap "raw" Zod shapes (e.g., { key: z.string }) into a z.object for convenience [6][1]. Important Note on Schema Types: Passing a plain JSON Schema object (instead of a Standard Schema compatible object) can cause the SDK to misinterpret the schema or silently drop it [7]. If you need to use a raw JSON Schema, you must wrap it using a tool like fromJsonSchema (available in @modelcontextprotocol/core) with an appropriate validator [8].

Citations:


🌐 Web query:

site:github.com/modelcontextprotocol/typescript-sdk registerTool inputSchema zod.parse handler

💡 Result:

In the Model Context Protocol (MCP) TypeScript SDK, the registerTool method allows you to define a tool with a schema for its arguments (inputSchema) and a handler function to execute the tool's logic [1][2]. Standard Pattern The recommended, native way to define the inputSchema is by passing a Standard Schema object, such as a Zod schema created with z.object({...}) [3][4][5]. When defined this way, the SDK automatically handles validation, and the handler receives the validated arguments as an object [3][6]. Example: server.registerTool( 'calculate-bmi', { title: 'BMI Calculator', description: 'Calculate Body Mass Index', inputSchema: z.object({ weightKg: z.number, heightM: z.number }), }, async ({ weightKg, heightM }) => { // Handler logic const bmi = weightKg / (heightM * heightM); return { content: [{ type: 'text', text: BMI is ${bmi} }], }; }); Compatibility for Raw Shapes For backward compatibility, the SDK also accepts raw Zod shapes (e.g., { weightKg: z.number(), heightM: z.number() }) [4][6]. If you pass a raw object shape, the SDK internally auto-wraps it with z.object() at runtime [4][6]. This usage is deprecated and intended to be removed in future major versions [6]. Key Notes: - Validation: The SDK uses the provided schema to validate the tool input before it reaches your handler [7]. - JSON Schema: The SDK uses these schemas to inform clients about the tool's expected input via the tools/list protocol message [7][5]. - Union Types: Support for complex schemas like z.union or z.discriminatedUnion is implemented to ensure they are correctly exposed to clients and validated [7]. - Library Agnostic: With the adoption of Standard Schema, the SDK is no longer strictly coupled to Zod; other libraries that support the Standard Schema interface can also be used [5][8].

Citations:


🌐 Web query:

site:github.com/modelcontextprotocol/typescript-sdk registerTool inputSchema zod.parse handler args unknown keys

💡 Result:

When working with Zod schemas in the Model Context Protocol (MCP) TypeScript SDK, the handling of unknown keys depends on the Zod configuration and the SDK version [1][2]. Default Zod object validation behavior is to strip unknown keys [1]. If you want your tool handlers or schema validation to behave differently, you can use the following Zod methods: 1..strip: This is the default Zod behavior. Any keys present in the input that are not defined in your schema are removed during validation [1]. 2..passthrough: Use this method if you want to preserve unknown keys in the validated output [1][3]. 3..strict: Use this method if you want validation to fail when unknown keys are present [1]. In the context of the MCP SDK, there has been a transition toward using default (stripping) behavior to improve type safety and consistency, moving away from older SDK versions that heavily utilized.passthrough [2]. If you are encountering issues where arguments are missing or being stripped unexpectedly: - Ensure you are passing the schema object correctly to the tool registration method [4]. - If you need to allow additional parameters that are not explicitly defined in your schema, chain.passthrough to your Zod object definition [1][3]. - When working with Zod v4 compatibility in newer SDK versions, be aware of how internal parsing methods are handled, as the SDK has undergone changes to maintain cross-version compatibility [5][6]. For general Zod parsing (outside of the direct tool handler flow), you can use Zod's.parse or.safeParse methods, which respect the.strip/.passthrough/.strict configuration defined on the schema [7][8].

Citations:


Mirror max_payload_chars and debug in get_memory_context. lib/services/pandora-mcp-server.ts:12 omits those fields from the tool registration schema, so MCP validation can drop or reject them before getMemoryContextTool sees them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/pandora-mcp-tools.ts` at line 19, The `get_memory_context` tool
registration schema in `pandora-mcp-server` is missing `max_payload_chars` and
`debug`, even though `memoryContextInputSchema` already defines them. Update the
schema used when registering `getMemoryContextTool` so it mirrors
`memoryContextInputSchema` exactly and accepts these two optional fields,
ensuring they reach `getMemoryContextTool` without MCP validation stripping or
rejecting them.

export const captureMemoryEventInputSchema = z.object({ namespace: namespaceSchema, raw_text: z.string().trim().min(1).max(8000), source: z.string().trim().max(120).optional(), source_ref: z.string().trim().max(500).optional(), importance: z.number().int().min(1).max(10).optional(), sensitivity: z.enum(["low", "medium", "high", "private"]).optional() });
export const distillContextPackInputSchema = z.object({ namespace: namespaceSchema, pack_type: z.enum(["daily", "master"]) });
const runtime = (capture = false, distill = false) => ({ config: { memoryCaptureApiEnabled: capture, memoryContextApiEnabled: true, memoryDistillationEnabled: distill }, gates: { memoryCaptureApiEnabled: { envVar: "PANDORA_ENABLE_MCP_CAPTURE" }, memoryContextApiEnabled: { envVar: "PANDORA_ENABLE_MCP" }, memoryDistillationEnabled: { envVar: "PANDORA_ENABLE_MCP_DISTILLATION" } } }) as never;
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/context-stabilization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it } from "vitest";
import { extractPeopleMentions, compactContextResponse } from "@/lib/services/memory-distillation-service";

function ev(id: string, raw_text: string): any {
return { id, namespace: "au", user_id: "u", source: "chatgpt_user_direct", raw_text, status: "captured", created_by: "u", created_at: "2026-07-03T00:00:00Z" };
}

describe("Sprint 1 — stabilize output (people_map + payload)", () => {
it("drops junk capitalized sentence-openers and keeps real names", () => {
const events = [
ev("e1", "REINFORCED AU MEMORY RULE. Janine Tan is the character. She is agentic. Mang Jun is raw. Do not link real identity. Keep this. User asked to save."),
];
const people = extractPeopleMentions(events);
const names = people.map((p) => p.name);

expect(names).toContain("Janine Tan");
expect(names).toContain("Mang Jun");
for (const junk of ["The", "Do", "She", "He", "Keep", "User", "Rule", "Status", "Source"]) {
expect(names).not.toContain(junk);
}
});

it("counts each event once per person (no per-occurrence id duplication)", () => {
const events = [ev("e1", "Janine Tan. Janine Tan. Janine Tan smiled at Janine Tan.")];
const [person] = extractPeopleMentions(events);
expect(person.name).toBe("Janine Tan");
expect(person.event_ids).toEqual(["e1"]);
});

it("merges a single-token alias into its full name but keeps distinct aliases separate", () => {
const events = [
ev("e1", "Janine Tan arrived."),
ev("e2", "Janine waited."),
ev("e3", "Jana ran off."),
];
const names = extractPeopleMentions(events).map((p) => p.name);
expect(names).toContain("Janine Tan");
expect(names).not.toContain("Janine"); // merged into "Janine Tan"
expect(names).toContain("Jana"); // distinct alias kept
const janine = extractPeopleMentions(events).find((p) => p.name === "Janine Tan")!;
expect(janine.event_ids.sort()).toEqual(["e1", "e2"]);
});

it("caps people count and event ids per person", () => {
const firsts = ["Aaron", "Bella", "Cara", "Dana", "Ella", "Faye", "Gina", "Hana", "Iris", "Jane", "Kira", "Lena", "Mona", "Nora", "Opal"];
const manyPeople = firsts.map((first, i) => ev(`p${i}`, `${first} Zeta did a thing.`));
expect(extractPeopleMentions(manyPeople).length).toBe(12);

const manyEvents = Array.from({ length: 12 }, (_, i) => ev(`e${i}`, "Janine Tan noted something."));
const [person] = extractPeopleMentions(manyEvents);
expect(person.event_ids.length).toBe(8);
});

it("slims an oversized context response under the payload budget, and debug bypasses it", () => {
const pack: any = {
title: "Pandora master context pack",
summary: "short summary",
key_points: [],
active_projects: [],
people_map: [{ name: "Janine Tan", event_ids: Array.from({ length: 500 }, (_, i) => `event-${i}`), notes: ["a note"] }],
decisions: [],
risks: [],
open_loops: [],
};

const slim = compactContextResponse(pack, [], { max_payload_chars: 3000 });
expect(JSON.stringify(slim).length).toBeLessThanOrEqual(3000);
expect(slim.people_map[0].event_ids.length).toBeLessThanOrEqual(3);

const full = compactContextResponse(pack, [], { debug: true });
expect(JSON.stringify(full).length).toBeGreaterThan(3000);
expect(full.people_map[0].event_ids.length).toBe(500);
});
});
Loading