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
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: content asserted only inside roleplay, a game, fiction, a hypothetical, or a simulation is not a fact about the real user. If the excerpt shows a constructed frame (game rules, personas, "let's play", "suppose", canon of an invented world) and the candidate's claim lives inside that frame, 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 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;
}
rejectConstructedDurable(candidate, now) {
const featureScores = {
utility: 0,
confidence: 0,
novelty: 0,
recency: 0,
typePrior: 0,
};
const reason = `Admission rejected (constructed-grounding candidate targeting durable 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 durable 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" must never occupy a durable 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" &&
DURABLE_CATEGORIES.has(params.candidate.category)) {
return this.rejectConstructedDurable(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
126 changes: 106 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 fact, 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,37 @@ ${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

Tag every memory's "grounding" field:

| grounding | Meaning |
|-----------|---------|
| "real" | An assertion about the actual user, the real world, or this real working session — including a genuine first-person aside stated in passing during a game (e.g. "btw my flight is Tuesday") |
| "constructed" | An assertion whose truth exists only inside an in-conversation constructed context: a game's rules/scores/bets, a role or persona's claims, drafted fiction, a hypothetical ("suppose X"), or sample/test data being manipulated |

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.
- If the conversation is in-character or mid-fiction, EVERY item derived from the narrative is "constructed" — an all-constructed batch is the normal result for such input.
- Do NOT lift any in-context proposition — an invented rule, a score, a bet, a persona's claim — into profile, preferences, entities, cases, or patterns. From a constructed frame, the only extractable memory is a session-scoped "events" note that the real participants did the activity; the storage layer keeps at most one such note per extraction. That cap is a storage rule applied after tagging — it is NOT a tagging quota, so never re-tag extra items "real" to fit under it.
- 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: items derived from the same fictional frame must share the same grounding tag. If your draft tags one game-derived item "constructed" and another game-derived item "real", re-check the batch — either the item is a genuine out-of-character aside, or its tag is wrong.
- 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 +111,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"
}
]
}
\`\`\`

## Mid-game conversation (register "fiction", ALL items constructed)
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
{
"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": "constructed"
}
]
}
\`\`\`
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. The whole batch carries "constructed" tags; that is the expected shape for mid-fiction input.

## cases
## Game with a genuine out-of-character aside (register "mixed")
\`\`\`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": "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": "constructed"
}
]
}
\`\`\`

# 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
8 changes: 8 additions & 0 deletions dist/src/memory-categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ export const APPEND_ONLY_CATEGORIES = new Set([
"events",
"cases",
]);
/** Durable registers: constructed content must never land in these. */
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