Skip to content
Draft
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
69 changes: 67 additions & 2 deletions dist/src/admission-control.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { DURABLE_CATEGORIES } from "./memory-categories.js";
import { parseSmartMetadata } from "./smart-metadata.js";
const DEFAULT_WEIGHTS = {
utility: 0.1,
Expand Down Expand Up @@ -322,12 +323,19 @@ Candidate memory:
- Abstract: ${candidate.abstract}
- Overview: ${candidate.overview}
- Content: ${candidate.content}
- Grounding: ${candidate.grounding ?? "unknown (legacy payload, treat as real)"}
- Conversation register: ${candidate.conversationRegister ?? "unknown (legacy payload)"}

Score future usefulness on a 0.0-1.0 scale.

Use higher scores for durable preferences, profile facts, reusable procedures, and long-lived project/entity state.
The memory system stores six categories: profile (user identity), preferences (user tendencies), entities (long-lived project/entity state), events (things that happened), cases (problem + solution pairs), and patterns (reusable procedures).

Use higher scores for durable profile facts, preferences, entity state, patterns, and genuinely reusable cases.
Use moderate scores for events worth an episodic record.
Use lower scores for one-off chatter, low-signal situational remarks, thin restatements, and low-value transient details.

Grounding rule: this candidate's own grounding tag already passed the deterministic pre-admission check (a "constructed" tag is rejected before this scoring ever runs), but a mistagged or legacy candidate can still describe a claim that is true only WITHIN a fiction — a persona's invented trait from roleplay, a game's rules or score, drafted fiction, a hypothetical, or sample data. If the candidate's own content shows such a constructed frame and its claim lives inside it rather than being a claim ABOUT the fiction (e.g. that a session/game happened), score it near zero for the durable categories (profile, preferences, entities, cases, patterns) regardless of how well-formed it looks. A session-scoped events note that the participants did the activity is a claim ABOUT the fiction and may still score moderately.

Return JSON only:
{
"utility": 0.0,
Expand All @@ -347,6 +355,22 @@ function buildReason(details) {
export function scoreTypePrior(category, typePriors) {
return clamp01(typePriors[category], DEFAULT_TYPE_PRIORS[category]);
}
/**
* Grounding-aware type prior. The raw prior gives durable registers a large
* head start (default weights put 0.6 on this single feature); when the batch
* register says the conversation was fiction, that head start would launder a
* mislabeled in-fiction claim into profile/preferences. Cap the prior for
* durable categories at the events prior in that case; every other input
* keeps the raw prior untouched.
*/
export function scoreGroundedTypePrior(candidate, typePriors) {
const raw = scoreTypePrior(candidate.category, typePriors);
if (candidate.conversationRegister === "fiction" &&
DURABLE_CATEGORIES.has(candidate.category)) {
return Math.min(raw, clamp01(typePriors.events, DEFAULT_TYPE_PRIORS.events));
}
return raw;
}
export function scoreConfidenceSupport(candidate, conversationText) {
const candidateText = `${candidate.abstract}\n${candidate.content}`.trim();
const candidateTokens = tokenizeText(candidateText);
Expand Down Expand Up @@ -437,6 +461,36 @@ export class AdmissionController {
this.config = config;
this.debugLog = debugLog;
}
rejectConstructed(candidate, now) {
const featureScores = {
utility: 0,
confidence: 0,
novelty: 0,
recency: 0,
typePrior: 0,
};
const reason = `Admission rejected (constructed-grounding candidate category "${candidate.category}"; deterministic pre-admission short-circuit, no LLM call).`;
const audit = {
version: "amac-v1",
decision: "reject",
score: 0,
reason,
thresholds: {
reject: this.config.rejectThreshold,
admit: this.config.admitThreshold,
},
weights: this.config.weights,
feature_scores: featureScores,
matched_existing_memory_ids: [],
compared_existing_memory_ids: [],
max_similarity: 0,
evaluated_at: now,
grounding: candidate.grounding,
conversation_register: candidate.conversationRegister,
};
this.debugLog(`memory-lancedb-pro: admission-control: decision=reject (constructed short-circuit) candidate=${JSON.stringify(candidate.abstract.slice(0, 80))}`);
return { decision: "reject", audit };
}
async loadRelevantMatches(candidate, candidateVector, scopeFilter) {
if (!Array.isArray(candidateVector) || candidateVector.length === 0) {
return [];
Expand All @@ -453,12 +507,21 @@ export class AdmissionController {
}
async evaluate(params) {
const now = params.now ?? Date.now();
// Deterministic pre-admission short-circuit: a candidate tagged
// "constructed" is true only within the fiction, never about the real
// world — it must never be stored, in any category or register, no
// matter how any downstream score would blend. Reject before any LLM
// call. Candidates without grounding metadata (legacy payloads) fall
// through to normal scoring.
if (params.candidate.grounding === "constructed") {
return this.rejectConstructed(params.candidate, now);
}
const relevantMatches = await this.loadRelevantMatches(params.candidate, params.candidateVector, params.scopeFilter);
const utility = await scoreUtility(this.llm, this.config.utilityMode, params.candidate, params.conversationText);
const confidence = scoreConfidenceSupport(params.candidate, params.conversationText);
const novelty = scoreNoveltyFromMatches(params.candidateVector, relevantMatches);
const recency = scoreRecencyGap(now, relevantMatches, this.config.recency.halfLifeDays);
const typePrior = scoreTypePrior(params.candidate.category, this.config.typePriors);
const typePrior = scoreGroundedTypePrior(params.candidate, this.config.typePriors);
const featureScores = {
utility: utility.score,
confidence: confidence.score,
Expand Down Expand Up @@ -502,6 +565,8 @@ export class AdmissionController {
compared_existing_memory_ids: novelty.comparedIds,
max_similarity: novelty.maxSimilarity,
evaluated_at: now,
grounding: params.candidate.grounding,
conversation_register: params.candidate.conversationRegister,
};
this.debugLog(`memory-lancedb-pro: admission-control: decision=${audit.decision} hint=${audit.hint ?? "n/a"} score=${audit.score.toFixed(3)} candidate=${JSON.stringify(params.candidate.abstract.slice(0, 80))}`);
return { decision, hint, audit };
Expand Down
130 changes: 110 additions & 20 deletions dist/src/extraction-prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ ${conversationText}
- Runtime scaffolding or orchestration wrappers such as "[Subagent Context]", "[Subagent Task]", bootstrap wrappers, task envelopes, or agent instructions — these are execution metadata, NEVER store them as memories
- Recall queries / meta-questions: "Do you remember X?", "你还记得X吗?", "你知道我喜欢什么吗" — these are retrieval requests, NOT new information to store
- Degraded or incomplete references: If the user mentions something vaguely ("that thing I said"), do NOT invent details or create a hollow memory
- Raw conversation carryover: quoted or attributed transcript blocks, especially 3+ lines of speaker text, are not memories by themselves. Distill a concrete fact/preference/decision/entity from them or skip.
- Raw conversation carryover: quoted or attributed transcript blocks, especially 3+ lines of speaker text, are not memories by themselves. Distill a concrete profile detail, preference, entity state, event, case, or pattern from them or skip.
- System/runtime artifacts: content containing "System:", compaction notices, model-switch/session-reset traces, tool-call transcripts, raw JSON blobs, or similar internal execution traces must be rejected unless a clean user fact can be extracted.
- Fragment blobs: mixed filename shards, code snippets, metadata fields, or partial sentences that look like unprocessed context fragments should be skipped rather than preserved.
- Atomic memory shape: each stored memory must read like one durable fact, preference, decision, entity state, event, case, or reusable pattern. If a candidate reads like an excerpt, log, or raw transcript, compress it into one atomic statement or skip it.
- Atomic memory shape: each stored memory must read like one atomic profile fact, preference, entity state, event, case, or pattern. If a candidate reads like an excerpt, log, or raw transcript, compress it into one atomic statement or skip it.
- Length/distillation gate: if a candidate is longer than about 200 characters and reads like raw conversation instead of a distilled insight, rewrite it as a single factual statement before storing; if that is not possible, skip it.

# Memory Classification
Expand Down Expand Up @@ -66,6 +66,41 @@ ${conversationText}
- "Encountered problem A, used solution B" -> cases (not events)
- "General process for handling certain problems" -> patterns (not cases)

# Conversational Grounding

First judge the register of the WHOLE conversation once, then tag each memory item independently.

## Batch register (one judgment per extraction)

Set the top-level "conversation_register" field:

| conversation_register | Meaning |
|-----------------------|---------|
| "real" | An ordinary working/personal conversation about the actual user and the real world |
| "fiction" | The conversation is inside a constructed frame: a game in progress, roleplay, in-character dialogue, drafted fiction, a hypothetical scenario, or a sample-data exercise |
| "mixed" | A constructed frame AND genuine out-of-character content are interleaved |

## Per-item grounding

Grounding describes the truth-grounding of the ASSERTION ITSELF, not which register the conversation happened in. Ask: is this claim true about the real world, or true only inside the fiction?

Tag every memory's "grounding" field:

| grounding | Meaning |
|-----------|---------|
| "real" | The assertion is about the real world — INCLUDING an assertion ABOUT the fiction (e.g. "user and assistant played a one-round roleplay game where user was Admiral Vex" is a true statement about a real session, even though it describes fictional play). Also covers a genuine first-person aside stated in passing during a game (e.g. "btw my flight is Tuesday") |
| "constructed" | The assertion holds only WITHIN the fiction — true in-character, in-game, or in-story, but not a fact about the real user or the real world (e.g. "user's favorite drink is plasma coffee", a persona's invented backstory, a game's score or rules) |

One-line rule: **about-the-fiction is real; within-the-fiction is constructed.**

Rules:
- Grounding is judged PER ITEM, on that item's own content. There is no expected number of "real" or "constructed" tags per batch: a batch may be all-real, all-constructed, or anything between.
- A session-scoped "events" note that the real participants engaged in a game, roleplay, or other fiction is a REAL assertion — it is a true statement about what happened in the real session, not a claim that lives inside the fiction. Extract it like any other events item, under its natural category, with grounding "real".
- Do NOT lift any in-character proposition — an invented rule, a score, a bet, a persona's claim, a fictional preference or trait — into profile, preferences, entities, cases, or patterns. Such claims are true only within the fiction; if you extract one at all, tag it "constructed" so it is never mistaken for a fact about the real user.
- A real aside spoken during play is still "real" and should be extracted normally under its natural category, even though it occurred inside a constructed register.
- Self-consistency check before answering: an item asserting what happened in the real session (including a session that was itself a game) is "real"; an item asserting a claim that is only true inside the story/game/persona is "constructed". If your draft tags the session-summary note itself as "constructed", re-check — it almost certainly describes a real event and should be "real".
- If you are genuinely unsure about a single item, default to "real" — under-tagging as constructed risks losing a genuine fact.

# Three-Level Structure

Each memory contains three levels:
Expand All @@ -80,56 +115,111 @@ Each memory contains three levels:

# Few-shot Examples

## profile
Each example is a full output batch, because register and grounding are judged together.

## Ordinary working conversation (register "real", single memory)
\`\`\`json
{
"category": "profile",
"abstract": "User basic info: AI development engineer, 3 years LLM experience",
"overview": "## Background\\n- Occupation: AI development engineer\\n- Experience: 3 years LLM development\\n- Tech stack: Python, LangChain",
"content": "User is an AI development engineer with 3 years of LLM application development experience."
"conversation_register": "real",
"memories": [
{
"category": "cases",
"abstract": "LanceDB BigInt numeric handling issue",
"overview": "## Problem\\nLanceDB 0.26+ returns BigInt for numeric columns\\n\\n## Solution\\nCoerce values with Number(...) before arithmetic",
"content": "When LanceDB returns BigInt values, wrap them with Number() before doing arithmetic operations.",
"grounding": "real"
}
]
}
\`\`\`

## preferences
## Ordinary personal conversation (register "real", two memories)
\`\`\`json
{
"category": "preferences",
"abstract": "Python code style: No type hints, concise and direct",
"overview": "## Preference Domain\\n- Language: Python\\n- Topic: Code style\\n\\n## Details\\n- No type hints\\n- Concise function comments\\n- Direct implementation",
"content": "User prefers Python code without type hints, with concise function comments."
"conversation_register": "real",
"memories": [
{
"category": "profile",
"abstract": "User basic info: AI development engineer, 3 years LLM experience",
"overview": "## Background\\n- Occupation: AI development engineer\\n- Experience: 3 years LLM development\\n- Tech stack: Python, LangChain",
"content": "User is an AI development engineer with 3 years of LLM application development experience.",
"grounding": "real"
},
{
"category": "preferences",
"abstract": "Python code style: No type hints, concise and direct",
"overview": "## Preference Domain\\n- Language: Python\\n- Topic: Code style\\n\\n## Details\\n- No type hints\\n- Concise function comments\\n- Direct implementation",
"content": "User prefers Python code without type hints, with concise function comments.",
"grounding": "real"
}
]
}
\`\`\`

## cases
## Mid-game conversation (register "fiction", session note is real; canon is not extracted)
Input was one round of an in-character guessing game where a persona claimed to live on a moon base and named an invented drink.
\`\`\`json
{
"category": "cases",
"abstract": "LanceDB BigInt numeric handling issue",
"overview": "## Problem\\nLanceDB 0.26+ returns BigInt for numeric columns\\n\\n## Solution\\nCoerce values with Number(...) before arithmetic",
"content": "When LanceDB returns BigInt values, wrap them with Number() before doing arithmetic operations."
"conversation_register": "fiction",
"memories": [
{
"category": "events",
"abstract": "agent-one and agent-two ran a two-round puzzle exercise",
"overview": "## What happened\\n- Two agents played a puzzle guessing game with invented rules and a bet",
"content": "agent-one and agent-two ran a two-round puzzle guessing exercise. The house rules, scores, and bet are part of the game, not durable facts.",
"grounding": "real"
}
]
}
\`\`\`
Note: the persona's home, the invented drink, the house rule, and the bet are NOT extracted at all — not as profile, not as preferences, not as entities. This session note is a true statement about a real session (the session happened), so it carries grounding "real" even though the batch register is "fiction" — about-the-fiction is real.

## Game with a genuine out-of-character aside (register "mixed")
\`\`\`json
{
"conversation_register": "mixed",
"memories": [
{
"category": "events",
"abstract": "User mentioned their new laptop arrives Thursday",
"overview": "## Real-world aside\\n- Stated in passing during a game",
"content": "In the middle of the game the user mentioned, out of character, that their new laptop arrives Thursday.",
"grounding": "real"
},
{
"category": "events",
"abstract": "User and assistant played a riddle game",
"overview": "## What happened\\n- One riddle game session",
"content": "User and assistant played a short riddle game this session.",
"grounding": "real"
}
]
}
\`\`\`

# Output Format

Return JSON:
{
"conversation_register": "real|mixed|fiction",
"memories": [
{
"category": "profile|preferences|entities|events|cases|patterns",
"abstract": "One-line index",
"overview": "Structured Markdown summary",
"content": "Full narrative"
"content": "Full narrative",
"grounding": "real|constructed"
}
]
}

Notes:
- Output language should match the dominant language in the conversation
- Only extract truly valuable personalized information
- If nothing worth recording, return {"memories": []}
- If nothing worth recording, return {"conversation_register": "real|mixed|fiction", "memories": []}
- Maximum 5 memories per extraction
- Preferences should be aggregated by topic`;
- Preferences should be aggregated by topic
- Always set the top-level "conversation_register" field, and tag every memory's "grounding" field, per the Conversational Grounding rules above`;
}
export function buildDedupPrompt(candidateAbstract, candidateOverview, candidateContent, existingMemories) {
return `Determine how to handle this candidate memory.
Expand Down
13 changes: 13 additions & 0 deletions dist/src/memory-categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ export const APPEND_ONLY_CATEGORIES = new Set([
"events",
"cases",
]);
/**
* Durable categories: governs fiction-register batch enforcement (an
* in-fiction batch can never produce durable memories) and the batch
* contradiction check. Per-item grounding "constructed" is dropped
* unconditionally in every category, so this set does not gate that rule.
*/
export const DURABLE_CATEGORIES = new Set([
"profile",
"preferences",
"entities",
"cases",
"patterns",
]);
/** Validate and normalize a category string. */
export function normalizeCategory(raw) {
const lower = raw.toLowerCase().trim();
Expand Down
Loading
Loading